feat: fix codeblock for mobile

This commit is contained in:
thecodrr
2022-06-02 07:26:44 +05:00
parent c9efde1b86
commit 8fbbfc0ead
35 changed files with 2319 additions and 357 deletions

View File

@@ -1,15 +1,16 @@
import { Node } from "@tiptap/core";
import { Selection } from "prosemirror-state";
import { Node as ProsemirrorNode } from "prosemirror-model";
export declare type IndentationOptions = {
type: "space" | "tab";
length: number;
};
interface Indent {
type: "tab" | "space";
amount: number;
}
export declare type CodeBlockAttributes = {
indentType: IndentationOptions["type"];
indentType: Indent["type"];
indentLength: number;
language: string;
lines: CodeLine[];
caretPosition?: CaretPosition;
};
export interface CodeBlockOptions {
/**
@@ -55,7 +56,7 @@ declare module "@tiptap/core" {
/**
* Change code block indentation options
*/
changeCodeBlockIndentation: (options: IndentationOptions) => ReturnType;
changeCodeBlockIndentation: (options: Indent) => ReturnType;
};
}
}
@@ -67,8 +68,9 @@ export declare type CaretPosition = {
line: number;
selected?: number;
total: number;
from: number;
};
export declare function toCaretPosition(lines: CodeLine[], selection: Selection): CaretPosition | undefined;
export declare function toCaretPosition(selection: Selection, lines?: CodeLine[]): CaretPosition | undefined;
export declare function getLines(node: ProsemirrorNode): CodeLine[];
declare type CodeLine = {
index: number;

View File

@@ -36,10 +36,13 @@ var __values = (this && this.__values) || function(o) {
};
import { Node, textblockTypeInputRule, mergeAttributes } from "@tiptap/core";
import { Plugin, PluginKey, TextSelection, } from "prosemirror-state";
import { findParentNodeClosestToPos, ReactNodeViewRenderer } from "../react";
import { findParentNodeClosestToPos } from "../react";
import { CodeblockComponent } from "./component";
import { HighlighterPlugin } from "./highlighter";
import ReactNodeView from "../react/ReactNodeView";
import detectIndent from "detect-indent";
import redent from "redent";
import stripIndent from "strip-indent";
export var backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
export var tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
var ZERO_WIDTH_SPACE = "\u200b";
@@ -63,6 +66,10 @@ export var CodeBlock = Node.create({
addAttributes: function () {
var _this = this;
return {
caretPosition: {
default: undefined,
rendered: false,
},
lines: {
default: [],
rendered: false,
@@ -71,9 +78,7 @@ export var CodeBlock = Node.create({
default: "space",
parseHTML: function (element) {
var indentType = element.dataset.indentType;
if (indentType)
return indentType;
return detectIndent(element.innerText).type;
},
renderHTML: function (attributes) {
if (!attributes.indentType) {
@@ -88,9 +93,7 @@ export var CodeBlock = Node.create({
default: 2,
parseHTML: function (element) {
var indentLength = element.dataset.indentLength;
if (indentLength)
return indentLength;
return detectIndent(element.innerText).amount;
},
renderHTML: function (attributes) {
if (!attributes.indentLength) {
@@ -146,7 +149,7 @@ export var CodeBlock = Node.create({
return [
"pre",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
["code", {}, 0],
];
},
addCommands: function () {
@@ -183,7 +186,10 @@ export var CodeBlock = Node.create({
if (!whitespaceLength)
continue;
var indentLength = whitespaceLength;
var indentToken = indent(options.type, indentLength);
var indentToken = indent({
type: options.type,
amount: indentLength,
});
tr.insertText(indentToken, tr.mapping.map(line.from), tr.mapping.map(line.from + whitespaceLength));
}
}
@@ -196,7 +202,7 @@ export var CodeBlock = Node.create({
}
commands.updateAttributes(_this.type, {
indentType: options.type,
indentLength: options.length,
indentLength: options.amount,
});
return true;
};
@@ -229,7 +235,7 @@ export var CodeBlock = Node.create({
return false;
}
if (isAtStart || !$anchor.parent.textContent.length) {
return _this.editor.commands.clearNodes();
return _this.editor.commands.deleteNode(_this.type);
}
return false;
},
@@ -301,7 +307,7 @@ export var CodeBlock = Node.create({
return false;
}
var indentation = parseIndentation($from.parent);
var indentToken = indent(indentation.type, indentation.length);
var indentToken = indent(indentation);
var lines = $from.parent.attrs.lines;
var selectedLines = getSelectedLines(lines, selection);
return editor
@@ -315,7 +321,7 @@ export var CodeBlock = Node.create({
var line = selectedLines_1_1.value;
if (line.text(indentToken.length) !== indentToken)
continue;
tr.delete(tr.mapping.map(line.from), tr.mapping.map(line.from + indentation.length));
tr.delete(tr.mapping.map(line.from), tr.mapping.map(line.from + indentation.amount));
}
}
catch (e_2_1) { e_2 = { error: e_2_1 }; }
@@ -345,8 +351,7 @@ export var CodeBlock = Node.create({
var tr = _a.tr;
return withSelection(tr, function (tr) {
var e_3, _a;
var indentation = parseIndentation($from.parent);
var indentToken = indent(indentation.type, indentation.length);
var indentToken = indent(parseIndentation($from.parent));
if (selectedLines.length === 1)
return tr.insertText(indentToken, $from.pos);
try {
@@ -398,10 +403,6 @@ export var CodeBlock = Node.create({
if (!event.clipboardData) {
return false;
}
// dont create a new code block within code blocks
if (_this.editor.isActive(_this.type.name)) {
return false;
}
var text = event.clipboardData.getData("text/plain");
var vscode = event.clipboardData.getData("vscode-editor-data");
var vscodeData = vscode ? JSON.parse(vscode) : undefined;
@@ -409,15 +410,26 @@ export var CodeBlock = Node.create({
if (!text || !language) {
return false;
}
var indent = fixIndentation(text, parseIndentation(view.state.selection.$from.parent));
var tr = view.state.tr;
// create an empty code block
tr.replaceSelectionWith(_this.type.create({ language: language }));
// put cursor inside the newly created code block
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(0, tr.selection.from - 2))));
// create an empty code block if not already within one
if (!_this.editor.isActive(_this.type.name)) {
tr.replaceSelectionWith(_this.type.create({
language: language,
indentType: indent.type,
indentLength: indent.amount,
}));
}
// // put cursor inside the newly created code block
// tr.setSelection(
// TextSelection.near(
// tr.doc.resolve(Math.max(0, tr.selection.from - 2))
// )
// );
// add text to code block
// strip carriage return chars from text pasted as code
// see: https://github.com/ProseMirror/prosemirror-view/commit/a50a6bcceb4ce52ac8fcc6162488d8875613aacd
tr.insertText(text.replace(/\r\n?/g, "\n"));
tr.insertText(indent.code.replace(/\r\n?/g, "\n"));
// store meta information
// this is useful for other plugins that depends on the paste event
// like the paste rule plugin
@@ -431,14 +443,31 @@ export var CodeBlock = Node.create({
];
},
addNodeView: function () {
return ReactNodeViewRenderer(CodeblockComponent);
return ReactNodeView.fromComponent(CodeblockComponent, {
contentDOMFactory: function () {
var content = document.createElement("div");
content.classList.add("node-content-wrapper");
content.style.whiteSpace = "inherit";
// caret is not visible if content element width is 0px
content.style.minWidth = "20px";
return { dom: content };
},
shouldUpdate: function (_a, _b) {
var prev = _a.attrs;
var next = _b.attrs;
return (compareCaretPosition(prev.caretPosition, next.caretPosition) ||
prev.language !== next.language ||
prev.indentType !== next.indentType);
},
});
},
});
export function toCaretPosition(lines, selection) {
export function toCaretPosition(selection, lines) {
var e_4, _a;
var $from = selection.$from, $to = selection.$to, $head = selection.$head;
if ($from.parent.type.name !== CodeBlock.name)
return;
lines = lines || getLines($from.parent);
try {
for (var lines_2 = __values(lines), lines_2_1 = lines_2.next(); !lines_2_1.done; lines_2_1 = lines_2.next()) {
var line = lines_2_1.value;
@@ -449,6 +478,7 @@ export function toCaretPosition(lines, selection) {
column: lineLength - (line.to - $head.pos),
selected: $to.pos - $from.pos,
total: lines.length,
from: line.from,
};
}
}
@@ -483,16 +513,28 @@ function exitOnTripleEnter(editor, $from) {
.run();
}
function indentOnEnter(editor, $from, options) {
var _a = getNewline($from, options) || {}, indentation = _a.indentation, newline = _a.newline;
if (!newline)
return false;
return editor
.chain()
.insertContent("".concat(newline).concat(indentation), {
parseOptions: { preserveWhitespace: "full" },
})
.focus()
.run();
}
function getNewline($from, options) {
var lines = $from.parent.attrs.lines;
var currentLine = getLineAt(lines, $from.pos);
if (!currentLine)
return false;
var text = editor.state.doc.textBetween(currentLine.from, currentLine.to);
var text = currentLine.text();
var indentLength = text.length - text.trimStart().length;
var newline = "".concat(NEWLINE).concat(indent(options.type, indentLength));
return editor.commands.insertContent(newline, {
parseOptions: { preserveWhitespace: "full" },
});
return {
newline: NEWLINE,
indentation: indent({ amount: indentLength, type: options.type }),
};
}
export function toCodeLines(code, pos) {
var positions = [];
@@ -533,10 +575,12 @@ function getSelectedLines(lines, selection) {
});
}
function parseIndentation(node) {
if (node.type.name !== CodeBlock.name)
return undefined;
var _a = node.attrs, indentType = _a.indentType, indentLength = _a.indentLength;
return {
type: indentType,
length: parseInt(indentLength),
amount: parseInt(indentLength),
};
}
function getLineAt(lines, pos) {
@@ -545,9 +589,14 @@ function getLineAt(lines, pos) {
function inRange(x, a, b) {
return x >= a && x <= b;
}
function indent(type, length) {
var char = type === "space" ? " " : "\t";
return char.repeat(length);
function indent(options) {
var char = options.type === "space" ? " " : "\t";
return char.repeat(options.amount);
}
function compareCaretPosition(prev, next) {
return (next === undefined ||
(prev === null || prev === void 0 ? void 0 : prev.column) !== (next === null || next === void 0 ? void 0 : next.column) ||
(prev === null || prev === void 0 ? void 0 : prev.line) !== (next === null || next === void 0 ? void 0 : next.line));
}
/**
* Persist selection between transaction steps
@@ -558,3 +607,11 @@ function withSelection(tr, callback) {
tr.setSelection(new TextSelection(tr.doc.resolve(tr.mapping.map($anchor.pos)), tr.doc.resolve(tr.mapping.map($head.pos))));
return true;
}
function fixIndentation(code, indent) {
var _a = indent || detectIndent(code), amount = _a.amount, _b = _a.type, type = _b === void 0 ? "space" : _b;
var fixed = redent(code, amount, {
includeEmptyLines: false,
indent: type === "space" ? " " : "\t",
});
return { code: stripIndent(fixed), amount: amount, type: type };
}

View File

@@ -1,3 +1,4 @@
import { NodeViewProps } from "../react";
import "prism-themes/themes/prism-dracula.min.css";
export declare function CodeblockComponent(props: NodeViewProps): JSX.Element;
import { CodeBlockAttributes } from "./code-block";
import { ReactComponentProps } from "../react/types";
export declare function CodeblockComponent(props: ReactComponentProps<CodeBlockAttributes>): JSX.Element;

View File

@@ -62,9 +62,8 @@ var __read = (this && this.__read) || function (o, n) {
return ar;
};
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { NodeViewContent, NodeViewWrapper } from "../react";
import { useEffect, useRef, useState } from "react";
import { loadLanguage } from "./loader";
import { isLanguageLoaded, loadLanguage } from "./loader";
import { refractor } from "refractor/lib/core";
import "prism-themes/themes/prism-dracula.min.css";
import { ThemeProvider } from "emotion-theming";
@@ -74,13 +73,12 @@ import { PopupPresenter } from "../../components/menu/menu";
import { Input } from "@rebass/forms";
import { Icon } from "../../toolbar/components/icon";
import { Icons } from "../../toolbar/icons";
import { toCaretPosition, getLines, } from "./code-block";
export function CodeblockComponent(props) {
var editor = props.editor, updateAttributes = props.updateAttributes, node = props.node;
var _a = node.attrs, language = _a.language, indentLength = _a.indentLength, indentType = _a.indentType;
var theme = editor.storage.theme;
var editor = props.editor, updateAttributes = props.updateAttributes, node = props.node, forwardRef = props.forwardRef;
var _a = node === null || node === void 0 ? void 0 : node.attrs, language = _a.language, indentLength = _a.indentLength, indentType = _a.indentType, caretPosition = _a.caretPosition;
var theme = editor === null || editor === void 0 ? void 0 : editor.storage.theme;
var _b = __read(useState(false), 2), isOpen = _b[0], setIsOpen = _b[1];
var _c = __read(useState(), 2), caretPosition = _c[0], setCaretPosition = _c[1];
// const [caretPosition, setCaretPosition] = useState<CaretPosition>();
var toolbarRef = useRef(null);
var languageDefinition = Languages.find(function (l) { var _a; return l.filename === language || ((_a = l.alias) === null || _a === void 0 ? void 0 : _a.some(function (a) { return a === language; })); });
useEffect(function () {
@@ -90,10 +88,8 @@ export function CodeblockComponent(props) {
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (!language || !languageDefinition) {
updateAttributes({ language: null });
if (!language || !languageDefinition || isLanguageLoaded(language))
return [2 /*return*/];
}
return [4 /*yield*/, loadLanguage(languageDefinition.filename)];
case 1:
syntax = _a.sent();
@@ -108,24 +104,13 @@ export function CodeblockComponent(props) {
});
});
})();
}, [language, updateAttributes]);
useEffect(function () {
function onSelectionUpdate(_a) {
var transaction = _a.transaction;
var position = toCaretPosition(getLines(node), transaction.selection);
setCaretPosition(position);
}
editor.on("selectionUpdate", onSelectionUpdate);
return function () {
editor.off("selectionUpdate", onSelectionUpdate);
};
}, [node]);
return (_jsx(NodeViewWrapper, { children: _jsxs(ThemeProvider, __assign({ theme: theme }, { children: [_jsxs(Flex, __assign({ sx: {
}, [language]);
return (_jsxs(ThemeProvider, __assign({ theme: theme }, { children: [_jsxs(Flex, __assign({ sx: {
flexDirection: "column",
borderRadius: "default",
overflow: "hidden",
} }, { children: [_jsx(Text, __assign({ as: "pre", sx: {
"div, span.token, span.line-number-widget": {
} }, { children: [_jsx(Text, { ref: forwardRef, as: "pre", sx: {
"div, span.token, span.line-number-widget, span.line-number::before": {
fontFamily: "monospace",
fontSize: "code",
whiteSpace: "pre !important",
@@ -140,7 +125,7 @@ export function CodeblockComponent(props) {
px: 2,
pt: 2,
pb: 1,
}, spellCheck: false }, { children: _jsx(NodeViewContent, { as: "code" }) })), _jsxs(Flex, __assign({ ref: toolbarRef, sx: {
}, spellCheck: false }), _jsxs(Flex, __assign({ ref: toolbarRef, contentEditable: false, sx: {
bg: "codeBg",
alignItems: "center",
justifyContent: "end",
@@ -150,7 +135,7 @@ export function CodeblockComponent(props) {
: ""] }))) : null, _jsx(Button, __assign({ variant: "icon", sx: { p: 1, mr: 1, ":hover": { bg: "codeSelection" } }, title: "Toggle indentation mode", onClick: function () {
editor.commands.changeCodeBlockIndentation({
type: indentType === "space" ? "tab" : "space",
length: indentLength,
amount: indentLength,
});
} }, { children: _jsxs(Text, __assign({ variant: "subBody", sx: { color: "codeFg" } }, { children: [indentType === "space" ? "Spaces" : "Tabs", ": ", indentLength] })) })), _jsx(Button, __assign({ variant: "icon", sx: {
p: 1,
@@ -169,7 +154,7 @@ export function CodeblockComponent(props) {
location: "top",
yOffset: 5,
},
} }, { children: _jsx(LanguageSelector, { selectedLanguage: (languageDefinition === null || languageDefinition === void 0 ? void 0 : languageDefinition.filename) || "Plaintext", onLanguageSelected: function (language) { return updateAttributes({ language: language }); } }) }))] })) }));
} }, { children: _jsx(LanguageSelector, { selectedLanguage: (languageDefinition === null || languageDefinition === void 0 ? void 0 : languageDefinition.filename) || "Plaintext", onLanguageSelected: function (language) { return updateAttributes({ language: language }); } }) }))] })));
}
function LanguageSelector(props) {
var onLanguageSelected = props.onLanguageSelected, selectedLanguage = props.selectedLanguage;

View File

@@ -49,7 +49,7 @@ import { Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { findChildren } from "@tiptap/core";
import { refractor } from "refractor/lib/core";
import { getLines, toCaretPosition, toCodeLines, } from "./code-block";
import { toCaretPosition, toCodeLines, } from "./code-block";
function parseNodes(nodes, className) {
if (className === void 0) { className = []; }
return nodes.reduce(function (result, node) {
@@ -58,6 +58,9 @@ function parseNodes(nodes, className) {
var classes = __spreadArray([], __read(className), false);
if (node.type === "element" && node.properties)
classes.push.apply(classes, __spreadArray([], __read(node.properties.className), false));
// this is required so that even plain text is wrapped in a span
// during highlighting. Without this, Prosemirror's selection acts
// weird for the first highlighted node/span.
else
classes.push("token", "text");
if (node.type === "element") {
@@ -73,19 +76,24 @@ function getHighlightNodes(result) {
return result.children || [];
}
function getLineDecoration(from, line, total, isActive) {
var maxLength = String(total).length;
var attributes = {
class: "line-number ".concat(isActive ? "active" : ""),
"data-line": String(line).padEnd(String(total).length, " "),
"data-line": String(line).padEnd(maxLength, " "),
};
var spec = {
line: line,
active: isActive,
total: total,
from: from,
};
// Prosemirror has a selection issue with the widget decoration
// on the first line. To work around that we use inline decoration
// for the first line.
if (line === 1) {
if (line === 1 ||
// Android Composition API (aka the virtual keyboard) doesn't behave well
// with Decoration widgets so we have to resort to inline line numbers.
isAndroid()) {
return Decoration.inline(from, from + 1, attributes, spec);
}
return Decoration.widget(from, function () {
@@ -95,21 +103,26 @@ function getLineDecoration(from, line, total, isActive) {
element.classList.add("active");
element.innerHTML = attributes["data-line"];
return element;
}, __assign(__assign({}, spec), { key: "".concat(line, "-").concat(isActive ? "active" : "inactive") }));
}, __assign(__assign({}, spec), {
// should rerender when any of these change:
// 1. line number
// 2. line active state
// 3. the max length of all lines
key: "".concat(line, "-").concat(isActive ? "active" : "", "-").concat(maxLength) }));
}
function getDecorations(_a) {
var doc = _a.doc, name = _a.name, defaultLanguage = _a.defaultLanguage, currentLine = _a.currentLine;
var doc = _a.doc, name = _a.name, defaultLanguage = _a.defaultLanguage, caretPosition = _a.caretPosition;
var decorations = [];
var languages = refractor.listLanguages();
findChildren(doc, function (node) { return node.type.name === name; }).forEach(function (block) {
var e_1, _a;
var code = block.node.textContent;
var lines = block.node.attrs.lines;
var lines = toCodeLines(code, block.pos);
try {
for (var _b = __values(lines || []), _c = _b.next(); !_c.done; _c = _b.next()) {
var line = _c.value;
var lineNumber = line.index + 1;
var isActive = lineNumber === currentLine;
var isActive = lineNumber === (caretPosition === null || caretPosition === void 0 ? void 0 : caretPosition.line) && line.from === (caretPosition === null || caretPosition === void 0 ? void 0 : caretPosition.from);
var decoration = getLineDecoration(line.from, lineNumber, (lines === null || lines === void 0 ? void 0 : lines.length) || 0, isActive);
decorations.push(decoration);
}
@@ -155,7 +168,12 @@ export function HighlighterPlugin(_a) {
var newNodeName = newState.selection.$head.parent.type.name;
var oldNodes = findChildren(oldState.doc, function (node) { return node.type.name === name; });
var newNodes = findChildren(newState.doc, function (node) { return node.type.name === name; });
var position = toCaretPosition(getLines(newState.selection.$head.parent), newState.selection);
var position = toCaretPosition(newState.selection);
// const isDocChanged =
// transaction.docChanged &&
// // TODO
// !transaction.steps.every((step) => step instanceof ReplaceAroundStep);
// console.log("Selection", transaction.docChanged, isDocChanged);
if (transaction.docChanged &&
// Apply decorations if:
// selection includes named node,
@@ -177,7 +195,7 @@ export function HighlighterPlugin(_a) {
doc: transaction.doc,
name: name,
defaultLanguage: defaultLanguage,
currentLine: position === null || position === void 0 ? void 0 : position.line,
caretPosition: position,
});
}
decorationSet = getActiveLineDecorations(transaction.doc, decorationSet, position);
@@ -189,17 +207,29 @@ export function HighlighterPlugin(_a) {
return key.getState(state);
},
},
appendTransaction: function (transactions, _prevState, nextState) {
appendTransaction: function (transactions, prevState, nextState) {
var tr = nextState.tr;
var modified = false;
if (transactions.some(function (transaction) { return transaction.docChanged; })) {
var docChanged = transactions.some(function (transaction) { return transaction.docChanged; });
var selectionChanged = (nextState.selection.$from.parent.type.name === name ||
prevState.selection.$from.parent.type.name === name) &&
prevState.selection.$from.pos !== nextState.selection.$from.pos;
findChildren(nextState.doc, function (node) { return node.type.name === name; }).forEach(function (block) {
var node = block.node, pos = block.pos;
var attributes = __assign({}, node.attrs);
if (docChanged) {
var lines = toCodeLines(node.textContent, pos);
tr.setNodeMarkup(pos, undefined, __assign(__assign({}, node.attrs), { lines: lines }));
modified = true;
});
attributes.lines = lines.slice();
}
if (selectionChanged) {
var position = toCaretPosition(nextState.selection, docChanged ? toCodeLines(node.textContent, pos) : undefined);
attributes.caretPosition = position;
}
if (docChanged || selectionChanged) {
tr.setNodeMarkup(pos, node.type, attributes);
modified = true;
}
});
return modified ? tr : null;
},
});
@@ -211,8 +241,11 @@ export function HighlighterPlugin(_a) {
function getActiveLineDecorations(doc, decorations, position) {
var e_2, _a;
var lineDecorations = decorations.find(undefined, undefined, function (_a) {
var line = _a.line, active = _a.active;
return (position && line === position.line) || active;
var line = _a.line, active = _a.active, from = _a.from;
var isSame = position
? line === position.line && from === position.from
: false;
return isSame || active;
});
if (!lineDecorations.length)
return decorations;
@@ -241,3 +274,7 @@ function getActiveLineDecorations(doc, decorations, position) {
}
return decorations.add(doc, newDecorations);
}
function isAndroid() {
var ua = navigator.userAgent.toLowerCase();
return ua.indexOf("android") > -1; //&& ua.indexOf("mobile");
}

View File

@@ -1 +1,2 @@
export declare function isLanguageLoaded(name: string): boolean;
export declare function loadLanguage(shortName: string): Promise<import("refractor/lib/core").Syntax | undefined>;

View File

@@ -35,6 +35,9 @@ var __generator = (this && this.__generator) || function (thisArg, body) {
}
};
var loadedLanguages = {};
export function isLanguageLoaded(name) {
return !!loadedLanguages[name];
}
export function loadLanguage(shortName) {
return __awaiter(this, void 0, void 0, function () {
var url, result;

View File

@@ -0,0 +1,50 @@
import React from "react";
import { NodeView, Decoration, DecorationSource } from "prosemirror-view";
import { Node as PMNode } from "prosemirror-model";
import { PortalProviderAPI } from "./ReactNodeViewPortals";
import { EventDispatcher } from "./event-dispatcher";
import { ReactComponentProps, ReactNodeViewOptions, GetPos, ForwardRef, ContentDOM } from "./types";
import { Editor, NodeViewRendererProps } from "@tiptap/core";
export default class ReactNodeView<P> implements NodeView {
protected readonly editor: Editor;
protected readonly getPos: GetPos;
protected readonly portalProviderAPI: PortalProviderAPI;
protected readonly eventDispatcher: EventDispatcher;
protected readonly options: ReactNodeViewOptions<P>;
private domRef;
private contentDOMWrapper?;
contentDOM: HTMLElement | undefined;
node: PMNode;
constructor(node: PMNode, editor: Editor, getPos: GetPos, portalProviderAPI: PortalProviderAPI, eventDispatcher: EventDispatcher, options: ReactNodeViewOptions<P>);
/**
* This method exists to move initialization logic out of the constructor,
* so object can be initialized properly before calling render first time.
*
* Example:
* Instance properties get added to an object only after super call in
* constructor, which leads to some methods being undefined during the
* first render.
*/
init(): this;
private renderReactComponent;
createDomRef(): HTMLElement;
getContentDOM(): ContentDOM;
handleRef: (node: HTMLElement | null) => void;
private _handleRef;
render(props?: P, forwardRef?: ForwardRef): React.ReactElement<any> | null;
private updateAttributes;
update(node: PMNode, _decorations: readonly Decoration[], _innerDecorations: DecorationSource): boolean;
ignoreMutation(mutation: MutationRecord | {
type: "selection";
target: Element;
}): boolean;
viewShouldUpdate(nextNode: PMNode): boolean;
/**
* Copies the attributes from a ProseMirror Node to a DOM node.
* @param node The Prosemirror Node from which to source the attributes
*/
setDomAttrs(node: PMNode, element: HTMLElement): void;
get dom(): HTMLElement;
destroy(): void;
static fromComponent<TProps>(component: React.ComponentType<TProps & ReactComponentProps>, options?: Omit<ReactNodeViewOptions<TProps>, "component">): ({ node, getPos, editor }: NodeViewRendererProps) => ReactNodeView<TProps>;
}

View File

@@ -0,0 +1,241 @@
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __read = (this && this.__read) || function (o, n) {
var m = typeof Symbol === "function" && o[Symbol.iterator];
if (!m) return o;
var i = m.call(o), r, ar = [], e;
try {
while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
}
catch (error) { e = { error: error }; }
finally {
try {
if (r && !r.done && (m = i["return"])) m.call(i);
}
finally { if (e) throw e.error; }
}
return ar;
};
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
if (ar || !(i in from)) {
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
ar[i] = from[i];
}
}
return to.concat(ar || Array.prototype.slice.call(from));
};
import { jsx as _jsx } from "react/jsx-runtime";
var ReactNodeView = /** @class */ (function () {
function ReactNodeView(node, editor, getPos, portalProviderAPI, eventDispatcher, options) {
var _this = this;
this.editor = editor;
this.getPos = getPos;
this.portalProviderAPI = portalProviderAPI;
this.eventDispatcher = eventDispatcher;
this.options = options;
this.handleRef = function (node) { return _this._handleRef(node); };
this.node = node;
}
/**
* This method exists to move initialization logic out of the constructor,
* so object can be initialized properly before calling render first time.
*
* Example:
* Instance properties get added to an object only after super call in
* constructor, which leads to some methods being undefined during the
* first render.
*/
ReactNodeView.prototype.init = function () {
var _this = this;
this.domRef = this.createDomRef();
// this.setDomAttrs(this.node, this.domRef);
var _a = this.getContentDOM() || {
dom: undefined,
contentDOM: undefined,
}, contentDOMWrapper = _a.dom, contentDOM = _a.contentDOM;
if (this.domRef && contentDOMWrapper) {
this.domRef.appendChild(contentDOMWrapper);
this.contentDOM = contentDOM ? contentDOM : contentDOMWrapper;
this.contentDOMWrapper = contentDOMWrapper || contentDOM;
}
// @see ED-3790
// something gets messed up during mutation processing inside of a
// nodeView if DOM structure has nested plain "div"s, it doesn't see the
// difference between them and it kills the nodeView
this.domRef.classList.add("".concat(this.node.type.name, "-view-content-wrap"));
this.renderReactComponent(function () {
return _this.render(_this.options.props, _this.handleRef);
});
return this;
};
ReactNodeView.prototype.renderReactComponent = function (component) {
if (!this.domRef || !component) {
return;
}
this.portalProviderAPI.render(component, this.domRef);
};
ReactNodeView.prototype.createDomRef = function () {
if (this.options.wrapperFactory)
return this.options.wrapperFactory();
if (!this.node.isInline) {
return document.createElement("div");
}
var htmlElement = document.createElement("span");
return htmlElement;
};
ReactNodeView.prototype.getContentDOM = function () {
var _a, _b;
return (_b = (_a = this.options).contentDOMFactory) === null || _b === void 0 ? void 0 : _b.call(_a);
};
ReactNodeView.prototype._handleRef = function (node) {
var contentDOM = this.contentDOMWrapper || this.contentDOM;
// move the contentDOM node inside the inner reference after rendering
if (node && contentDOM && !node.contains(contentDOM)) {
node.appendChild(contentDOM);
}
};
ReactNodeView.prototype.render = function (props, forwardRef) {
var _this = this;
if (props === void 0) { props = {}; }
if (!this.options.component)
return null;
return (_jsx(this.options.component, __assign({}, props, { editor: this.editor, getPos: this.getPos, node: this.node, forwardRef: forwardRef, updateAttributes: function (attr) { return _this.updateAttributes(attr); } })));
};
ReactNodeView.prototype.updateAttributes = function (attributes) {
var _this = this;
this.editor.commands.command(function (_a) {
var tr = _a.tr;
if (typeof _this.getPos === "boolean")
return false;
var pos = _this.getPos();
tr.setNodeMarkup(pos, undefined, __assign(__assign({}, _this.node.attrs), attributes));
return true;
});
};
ReactNodeView.prototype.update = function (node, _decorations, _innerDecorations
// _innerDecorations?: Array<Decoration>,
// validUpdate: (currentNode: PMNode, newNode: PMNode) => boolean = () => true
) {
var _this = this;
// @see https://github.com/ProseMirror/prosemirror/issues/648
var isValidUpdate = this.node.type === node.type; // && validUpdate(this.node, node);
if (!isValidUpdate) {
return false;
}
// if (this.domRef && !this.node.sameMarkup(node)) {
// this.setDomAttrs(node, this.domRef);
// }
// View should not process a re-render if this is false.
// We dont want to destroy the view, so we return true.
if (!this.viewShouldUpdate(node)) {
this.node = node;
return true;
}
this.node = node;
this.renderReactComponent(function () {
return _this.render(_this.options.props, _this.handleRef);
});
return true;
};
ReactNodeView.prototype.ignoreMutation = function (mutation) {
if (!this.dom || !this.contentDOM) {
return true;
}
// TODO if (typeof this.options.ignoreMutation === 'function') {
// return this.options.ignoreMutation({ mutation })
// }
// a leaf/atom node is like a black box for ProseMirror
// and should be fully handled by the node view
if (this.node.isLeaf || this.node.isAtom) {
return true;
}
// ProseMirror should handle any selections
if (mutation.type === "selection") {
return false;
}
// try to prevent a bug on mobiles that will break node views on enter
// this is because ProseMirror cant preventDispatch on enter
// this will lead to a re-render of the node view on enter
// see: https://github.com/ueberdosis/tiptap/issues/1214
if (this.dom.contains(mutation.target) &&
mutation.type === "childList" &&
this.editor.isFocused) {
var changedNodes = __spreadArray(__spreadArray([], __read(Array.from(mutation.addedNodes)), false), __read(Array.from(mutation.removedNodes)), false);
// well check if every changed node is contentEditable
// to make sure its probably mutated by ProseMirror
if (changedNodes.every(function (node) { return node.isContentEditable; })) {
return false;
}
}
// we will allow mutation contentDOM with attributes
// so we can for example adding classes within our node view
if (this.contentDOM === mutation.target && mutation.type === "attributes") {
return true;
}
// ProseMirror should handle any changes within contentDOM
if (this.contentDOM.contains(mutation.target)) {
return false;
}
return true;
};
ReactNodeView.prototype.viewShouldUpdate = function (nextNode) {
if (this.options.shouldUpdate)
return this.options.shouldUpdate(this.node, nextNode);
return true;
};
/**
* Copies the attributes from a ProseMirror Node to a DOM node.
* @param node The Prosemirror Node from which to source the attributes
*/
ReactNodeView.prototype.setDomAttrs = function (node, element) {
Object.keys(node.attrs || {}).forEach(function (attr) {
element.setAttribute(attr, node.attrs[attr]);
});
};
Object.defineProperty(ReactNodeView.prototype, "dom", {
get: function () {
return this.domRef;
},
enumerable: false,
configurable: true
});
ReactNodeView.prototype.destroy = function () {
if (!this.domRef) {
return;
}
this.portalProviderAPI.remove(this.domRef);
// @ts-ignore NEW PM API
this.domRef = undefined;
this.contentDOM = undefined;
};
ReactNodeView.fromComponent = function (component, options) {
return function (_a) {
var node = _a.node, getPos = _a.getPos, editor = _a.editor;
return new ReactNodeView(node, editor, getPos, editor.storage.portalProviderAPI, editor.storage.eventDispatcher, __assign(__assign({}, options), { component: component })).init();
};
};
return ReactNodeView;
}());
export default ReactNodeView;
function isiOS() {
return ([
"iPad Simulator",
"iPhone Simulator",
"iPod Simulator",
"iPad",
"iPhone",
"iPod",
].includes(navigator.platform) ||
// iPad on iOS 13 detection
(navigator.userAgent.includes("Mac") && "ontouchend" in document));
}

View File

@@ -0,0 +1,38 @@
import React from "react";
import { EventDispatcher } from "./event-dispatcher";
export declare type BasePortalProviderProps = {
render: (portalProviderAPI: PortalProviderAPI) => React.ReactChild | JSX.Element | null;
};
export declare type Portals = Map<HTMLElement, React.ReactChild>;
export declare type PortalRendererState = {
portals: Portals;
};
declare type MountedPortal = {
children: () => React.ReactChild | null;
};
export declare class PortalProviderAPI extends EventDispatcher {
portals: Map<HTMLElement, MountedPortal>;
context: any;
constructor();
setContext: (context: any) => void;
render(children: () => React.ReactChild | JSX.Element | null, container: HTMLElement): void;
forceUpdate(): void;
remove(container: HTMLElement): void;
}
export declare class PortalProvider extends React.Component<BasePortalProviderProps> {
static displayName: string;
portalProviderAPI: PortalProviderAPI;
constructor(props: BasePortalProviderProps);
render(): React.ReactChild | JSX.Element | null;
componentDidUpdate(): void;
}
export declare class PortalRenderer extends React.Component<{
portalProviderAPI: PortalProviderAPI;
}, PortalRendererState> {
constructor(props: {
portalProviderAPI: PortalProviderAPI;
});
handleUpdate: (portals: Portals) => void;
render(): JSX.Element;
}
export {};

View File

@@ -0,0 +1,111 @@
var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== "function" && b !== null)
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
var __read = (this && this.__read) || function (o, n) {
var m = typeof Symbol === "function" && o[Symbol.iterator];
if (!m) return o;
var i = m.call(o), r, ar = [], e;
try {
while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);
}
catch (error) { e = { error: error }; }
finally {
try {
if (r && !r.done && (m = i["return"])) m.call(i);
}
finally { if (e) throw e.error; }
}
return ar;
};
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
import React from "react";
import { createPortal, unstable_renderSubtreeIntoContainer, unmountComponentAtNode, } from "react-dom";
import { EventDispatcher } from "./event-dispatcher";
var PortalProviderAPI = /** @class */ (function (_super) {
__extends(PortalProviderAPI, _super);
function PortalProviderAPI() {
var _this = _super.call(this) || this;
_this.portals = new Map();
_this.setContext = function (context) {
_this.context = context;
};
return _this;
}
PortalProviderAPI.prototype.render = function (children, container) {
this.portals.set(container, {
children: children,
});
var wrappedChildren = children();
unstable_renderSubtreeIntoContainer(this.context, wrappedChildren, container);
};
// TODO: until https://product-fabric.atlassian.net/browse/ED-5013
// we (unfortunately) need to re-render to pass down any updated context.
// selectively do this for nodeviews that opt-in via `hasAnalyticsContext`
PortalProviderAPI.prototype.forceUpdate = function () { };
PortalProviderAPI.prototype.remove = function (container) {
this.portals.delete(container);
// There is a race condition that can happen caused by Prosemirror vs React,
// where Prosemirror removes the container from the DOM before React gets
// around to removing the child from the container
// This will throw a NotFoundError: The node to be removed is not a child of this node
// Both Prosemirror and React remove the elements asynchronously, and in edge
// cases Prosemirror beats React
try {
unmountComponentAtNode(container);
}
catch (error) {
console.error(error);
}
};
return PortalProviderAPI;
}(EventDispatcher));
export { PortalProviderAPI };
var PortalProvider = /** @class */ (function (_super) {
__extends(PortalProvider, _super);
function PortalProvider(props) {
var _this = _super.call(this, props) || this;
_this.portalProviderAPI = new PortalProviderAPI();
return _this;
}
PortalProvider.prototype.render = function () {
return this.props.render(this.portalProviderAPI);
};
PortalProvider.prototype.componentDidUpdate = function () {
this.portalProviderAPI.forceUpdate();
};
PortalProvider.displayName = "PortalProvider";
return PortalProvider;
}(React.Component));
export { PortalProvider };
var PortalRenderer = /** @class */ (function (_super) {
__extends(PortalRenderer, _super);
function PortalRenderer(props) {
var _this = _super.call(this, props) || this;
_this.handleUpdate = function (portals) { return _this.setState({ portals: portals }); };
props.portalProviderAPI.setContext(_this);
props.portalProviderAPI.on("update", _this.handleUpdate);
_this.state = { portals: new Map() };
return _this;
}
PortalRenderer.prototype.render = function () {
var portals = this.state.portals;
return (_jsx(_Fragment, { children: Array.from(portals.entries()).map(function (_a) {
var _b = __read(_a, 2), container = _b[0], children = _b[1];
return createPortal(children, container);
}) }));
};
return PortalRenderer;
}(React.Component));
export { PortalRenderer };

View File

@@ -0,0 +1,54 @@
import React from "react";
import { Node as PMNode } from "prosemirror-model";
import { PortalProviderAPI } from "./ReactNodeViewPortals";
import { EventDispatcher } from "./event-dispatcher";
import { ReactComponentProps, GetPos, ReactNodeViewOptions } from "./types";
import ReactNodeView from "./ReactNodeView";
import { Editor, NodeViewRendererProps } from "@tiptap/core";
/**
* 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 declare class SelectionBasedNodeView<P = ReactComponentProps> extends ReactNodeView<P> {
private oldSelection;
private selectionChangeState;
pos: number | undefined;
posEnd: number | undefined;
constructor(node: PMNode, editor: Editor, getPos: GetPos, portalProviderAPI: PortalProviderAPI, eventDispatcher: EventDispatcher, options: ReactNodeViewOptions<P>);
/**
* 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;
private getPositionsWithDefault;
isNodeInsideSelection: (from: number, to: number, pos?: number, posEnd?: number) => boolean;
isSelectionInsideNode: (from: number, to: number, pos?: number, posEnd?: number) => boolean;
private isSelectedNode;
insideSelection: () => boolean;
nodeInsideSelection: () => boolean;
viewShouldUpdate(_nextNode: PMNode): boolean;
destroy(): void;
private onSelectionChange;
static fromComponent<TProps>(component: React.ComponentType<TProps & ReactComponentProps>, options?: Omit<ReactNodeViewOptions<TProps>, "component">): ({ node, getPos, editor }: NodeViewRendererProps) => SelectionBasedNodeView<TProps>;
}

View File

@@ -0,0 +1,164 @@
var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== "function" && b !== null)
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
import { DecorationSet } from "prosemirror-view";
import { NodeSelection } from "prosemirror-state";
import { stateKey as SelectionChangePluginKey, } from "./plugin";
import ReactNodeView from "./ReactNodeView";
/**
* 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);
* }```
*/
var SelectionBasedNodeView = /** @class */ (function (_super) {
__extends(SelectionBasedNodeView, _super);
function SelectionBasedNodeView(node, editor, getPos, portalProviderAPI, eventDispatcher, options) {
var _this = _super.call(this, node, editor, getPos, portalProviderAPI, eventDispatcher, options) || this;
_this.isNodeInsideSelection = function (from, to, pos, posEnd) {
var _a;
(_a = _this.getPositionsWithDefault(pos, posEnd), pos = _a.pos, posEnd = _a.posEnd);
if (typeof pos !== "number" || typeof posEnd !== "number") {
return false;
}
return from <= pos && to >= posEnd;
};
_this.isSelectionInsideNode = function (from, to, pos, posEnd) {
var _a;
(_a = _this.getPositionsWithDefault(pos, posEnd), pos = _a.pos, posEnd = _a.posEnd);
if (typeof pos !== "number" || typeof posEnd !== "number") {
return false;
}
return pos < from && to < posEnd;
};
_this.isSelectedNode = function (selection) {
if (selection instanceof NodeSelection) {
var _a = _this.editor.view.state.selection, from = _a.from, to = _a.to;
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;
};
_this.insideSelection = function () {
var _a = _this.editor.view.state.selection, from = _a.from, to = _a.to;
return (_this.isSelectedNode(_this.editor.view.state.selection) ||
_this.isSelectionInsideNode(from, to));
};
_this.nodeInsideSelection = function () {
var selection = _this.editor.view.state.selection;
var from = selection.from, to = selection.to;
return (_this.isSelectedNode(selection) || _this.isNodeInsideSelection(from, to));
};
_this.onSelectionChange = function () {
_this.update(_this.node, [], DecorationSet.empty);
};
_this.updatePos();
_this.oldSelection = editor.view.state.selection;
_this.selectionChangeState = SelectionChangePluginKey.getState(_this.editor.view.state);
_this.selectionChangeState.subscribe(_this.onSelectionChange);
return _this;
}
/**
* 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.
*/
SelectionBasedNodeView.prototype.updatePos = function () {
if (typeof this.getPos === "boolean") {
return;
}
this.pos = this.getPos();
this.posEnd = this.pos + this.node.nodeSize;
};
SelectionBasedNodeView.prototype.getPositionsWithDefault = function (pos, posEnd) {
return {
pos: typeof pos !== "number" ? this.pos : pos,
posEnd: typeof posEnd !== "number" ? this.posEnd : posEnd,
};
};
SelectionBasedNodeView.prototype.viewShouldUpdate = function (_nextNode) {
var selection = this.editor.view.state.selection;
// update selection
var oldSelection = this.oldSelection;
this.oldSelection = selection;
// update cached positions
var _a = this, oldPos = _a.pos, oldPosEnd = _a.posEnd;
this.updatePos();
var from = selection.from, to = selection.to;
var oldFrom = oldSelection.from, oldTo = oldSelection.to;
if (this.node.type.spec.selectable) {
var newNodeSelection = selection instanceof NodeSelection && selection.from === this.pos;
var oldNodeSelection = oldSelection instanceof NodeSelection && oldSelection.from === this.pos;
if ((newNodeSelection && !oldNodeSelection) ||
(oldNodeSelection && !newNodeSelection)) {
return true;
}
}
var movedInToSelection = this.isNodeInsideSelection(from, to) &&
!this.isNodeInsideSelection(oldFrom, oldTo);
var movedOutOfSelection = !this.isNodeInsideSelection(from, to) &&
this.isNodeInsideSelection(oldFrom, oldTo);
var moveOutFromOldSelection = this.isNodeInsideSelection(from, to, oldPos, oldPosEnd) &&
!this.isNodeInsideSelection(from, to);
if (movedInToSelection || movedOutOfSelection || moveOutFromOldSelection) {
return true;
}
return false;
};
SelectionBasedNodeView.prototype.destroy = function () {
this.selectionChangeState.unsubscribe(this.onSelectionChange);
_super.prototype.destroy.call(this);
};
SelectionBasedNodeView.fromComponent = function (component, options) {
return function (_a) {
var node = _a.node, getPos = _a.getPos, editor = _a.editor;
return new SelectionBasedNodeView(node, editor, getPos, editor.storage.portalProviderAPI, editor.storage.eventDispatcher, __assign(__assign({}, options), { component: component })).init();
};
};
return SelectionBasedNodeView;
}(ReactNodeView));
export { SelectionBasedNodeView };

View File

@@ -0,0 +1,18 @@
import { PluginKey } from "prosemirror-state";
export interface Listeners {
[name: string]: Set<Listener>;
}
export declare type Listener<T = any> = (data: T) => void;
export declare type Dispatch<T = any> = (eventName: PluginKey | string, data: T) => void;
export declare class EventDispatcher<T = any> {
private listeners;
on(event: string, cb: Listener<T>): void;
off(event: string, cb: Listener<T>): void;
emit(event: string, data: T): void;
destroy(): void;
}
/**
* Creates a dispatch function that can be called inside ProseMirror Plugin
* to notify listeners about that plugin's state change.
*/
export declare function createDispatch<T>(eventDispatcher: EventDispatcher<T>): Dispatch<T>;

View File

@@ -0,0 +1,45 @@
var EventDispatcher = /** @class */ (function () {
function EventDispatcher() {
this.listeners = {};
}
EventDispatcher.prototype.on = function (event, cb) {
if (!this.listeners[event]) {
this.listeners[event] = new Set();
}
this.listeners[event].add(cb);
};
EventDispatcher.prototype.off = function (event, cb) {
if (!this.listeners[event]) {
return;
}
if (this.listeners[event].has(cb)) {
this.listeners[event].delete(cb);
}
};
EventDispatcher.prototype.emit = function (event, data) {
if (!this.listeners[event]) {
return;
}
this.listeners[event].forEach(function (cb) { return cb(data); });
};
EventDispatcher.prototype.destroy = function () {
this.listeners = {};
};
return EventDispatcher;
}());
export { EventDispatcher };
/**
* Creates a dispatch function that can be called inside ProseMirror Plugin
* to notify listeners about that plugin's state change.
*/
export function createDispatch(eventDispatcher) {
return function (eventName, data) {
if (!eventName) {
throw new Error("event name is required!");
}
var event = typeof eventName === "string"
? eventName
: eventName.key;
eventDispatcher.emit(event, data);
};
}

View File

@@ -0,0 +1,13 @@
import { Plugin, PluginKey } from "prosemirror-state";
export declare type StateChangeHandler = (fromPos: number, toPos: number) => any;
export declare class ReactNodeViewState {
private changeHandlers;
constructor();
subscribe(cb: StateChangeHandler): void;
unsubscribe(cb: StateChangeHandler): void;
notifyNewSelection(fromPos: number, toPos: number): void;
}
export declare const stateKey: PluginKey<any>;
export declare const plugin: Plugin<ReactNodeViewState>;
declare const plugins: () => Plugin<ReactNodeViewState>[];
export default plugins;

View File

@@ -0,0 +1,41 @@
import { Plugin, PluginKey } from "prosemirror-state";
var ReactNodeViewState = /** @class */ (function () {
function ReactNodeViewState() {
this.changeHandlers = [];
this.changeHandlers = [];
}
ReactNodeViewState.prototype.subscribe = function (cb) {
this.changeHandlers.push(cb);
};
ReactNodeViewState.prototype.unsubscribe = function (cb) {
this.changeHandlers = this.changeHandlers.filter(function (ch) { return ch !== cb; });
};
ReactNodeViewState.prototype.notifyNewSelection = function (fromPos, toPos) {
this.changeHandlers.forEach(function (cb) { return cb(fromPos, toPos); });
};
return ReactNodeViewState;
}());
export { ReactNodeViewState };
export var stateKey = new PluginKey("reactNodeView");
export var plugin = new Plugin({
state: {
init: function () {
return new ReactNodeViewState();
},
apply: function (_tr, pluginState) {
return pluginState;
},
},
key: stateKey,
view: function (view) {
var pluginState = stateKey.getState(view.state);
return {
update: function (view) {
var _a = view.state.selection, from = _a.from, to = _a.to;
pluginState.notifyNewSelection(from, to);
},
};
},
});
var plugins = function () { return [plugin]; };
export default plugins;

View File

@@ -0,0 +1,31 @@
/// <reference types="react" />
import { Editor } from "@tiptap/core";
import { Node as PMNode, Attrs } from "prosemirror-model";
export interface ReactNodeProps {
selected: boolean;
}
export declare type GetPos = GetPosNode | boolean;
export declare type GetPosNode = () => number;
export declare type ForwardRef = (node: HTMLElement | null) => void;
export declare type ShouldUpdate = (prevNode: PMNode, nextNode: PMNode) => boolean;
export declare type UpdateAttributes<T> = (attributes: Partial<T>) => void;
export declare type ContentDOM = {
dom: HTMLElement;
contentDOM?: HTMLElement | null | undefined;
} | undefined;
export declare type ReactComponentProps<TAttributes = Attrs> = {
getPos: GetPos;
node: PMNode & {
attrs: TAttributes;
};
editor: Editor;
updateAttributes: UpdateAttributes<TAttributes>;
forwardRef?: ForwardRef;
};
export declare type ReactNodeViewOptions<P> = {
props?: P;
component?: React.ComponentType<P & ReactComponentProps>;
shouldUpdate?: ShouldUpdate;
contentDOMFactory?: () => ContentDOM;
wrapperFactory?: () => HTMLElement;
};

View File

@@ -0,0 +1 @@
export {};

View File

@@ -2,8 +2,10 @@ import { EditorOptions } from "./extensions/react";
import Toolbar from "./toolbar";
import { Theme } from "@notesnook/theme";
import { AttachmentOptions } from "./extensions/attachment";
import { PortalProviderAPI } from "./extensions/react/ReactNodeViewPortals";
declare const useTiptap: (options?: Partial<EditorOptions & AttachmentOptions & {
theme: Theme;
portalProviderAPI: PortalProviderAPI;
}>, deps?: React.DependencyList) => import("./extensions/react").Editor | null;
export { useTiptap, Toolbar };
export * from "./extensions/react";

View File

@@ -53,6 +53,7 @@ import { SearchReplace } from "./extensions/search-replace";
import { EmbedNode } from "./extensions/embed";
import { CodeBlock } from "./extensions/code-block";
import { ListItem } from "./extensions/list-item";
import { EventDispatcher } from "./extensions/react/event-dispatcher";
EditorView.prototype.updateState = function updateState(state) {
if (!this.docView)
return; // This prevents the matchesNode error on hot reloads
@@ -60,7 +61,8 @@ EditorView.prototype.updateState = function updateState(state) {
};
var useTiptap = function (options, deps) {
if (options === void 0) { options = {}; }
var theme = options.theme, onCreate = options.onCreate, onDownloadAttachment = options.onDownloadAttachment, restOptions = __rest(options, ["theme", "onCreate", "onDownloadAttachment"]);
var theme = options.theme, onCreate = options.onCreate, onDownloadAttachment = options.onDownloadAttachment, portalProviderAPI = options.portalProviderAPI, restOptions = __rest(options, ["theme", "onCreate", "onDownloadAttachment", "portalProviderAPI"]);
var eventDispatcher = useMemo(function () { return new EventDispatcher(); }, []);
var defaultOptions = useMemo(function () { return ({
extensions: [
SearchReplace,
@@ -119,11 +121,15 @@ var useTiptap = function (options, deps) {
if (theme) {
editor.storage.theme = theme;
}
if (portalProviderAPI)
editor.storage.portalProviderAPI = portalProviderAPI;
if (eventDispatcher)
editor.storage.eventDispatcher = eventDispatcher;
if (onCreate)
onCreate({ editor: editor });
},
injectCSS: false,
}); }, [theme, onCreate, onDownloadAttachment]);
}); }, [theme, onCreate, onDownloadAttachment, portalProviderAPI, eventDispatcher]);
var editor = useEditor(__assign(__assign({}, defaultOptions), restOptions), deps);
/**
* Add editor to global for use in React Native.

View File

@@ -35,6 +35,7 @@
"@tiptap/extension-underline": "^2.0.0-beta.23",
"@tiptap/starter-kit": "^2.0.0-beta.185",
"detect-indent": "^7.0.0",
"detect-indentation": "^5.20.0",
"emotion-theming": "^10.0.19",
"esm-loader": "^0.1.0",
"highlight.js": "^11.5.1",
@@ -52,7 +53,10 @@
"react-toggle": "^4.1.2",
"reactjs-popup": "^2.0.5",
"rebass": "^4.0.7",
"redent": "^4.0.0",
"refractor": "^4.7.0",
"shortid": "^2.2.16",
"strip-indent": "^4.0.0",
"tinycolor2": "^1.4.2",
"tippy.js": "^6.3.7",
"zustand": "^3.7.2"
@@ -68,6 +72,7 @@
"@types/react-toggle": "^4.0.3",
"@types/rebass": "^4.0.10",
"@types/rebass__forms": "^4.0.6",
"@types/shortid": "^0.0.29",
"@types/tinycolor2": "^1.4.3",
"esm": "^3.2.25",
"isomorphic-fetch": "^3.0.0",
@@ -4795,6 +4800,12 @@
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
"dev": true
},
"node_modules/@types/shortid": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/shortid/-/shortid-0.0.29.tgz",
"integrity": "sha512-9BCYD9btg2CY4kPcpMQ+vCR8U6V8f/KvixYD5ZbxoWlkhddNF5IeZMVL3p+QFUkg+Hb+kPAG9Jgk4bnnF1v/Fw==",
"dev": true
},
"node_modules/@types/source-list-map": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
@@ -8641,6 +8652,17 @@
"node": ">=12.20"
}
},
"node_modules/detect-indentation": {
"version": "5.20.0",
"resolved": "https://registry.npmjs.org/detect-indentation/-/detect-indentation-5.20.0.tgz",
"integrity": "sha512-nm/f3Pc8ESNfq5WV9x1wSZlzXrBopcjsRLmQahHqBqV8M1XgU3oWhj4xSJuMT5m0I7La7Z4G6983g7bigP6rmg==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://bevry.me/fund"
}
},
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -15709,6 +15731,14 @@
"node": ">=6"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"engines": {
"node": ">=4"
}
},
"node_modules/mini-css-extract-plugin": {
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.11.3.tgz",
@@ -19725,6 +19755,32 @@
"node": "*"
}
},
"node_modules/redent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz",
"integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==",
"dependencies": {
"indent-string": "^5.0.0",
"strip-indent": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/redent/node_modules/indent-string": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
"integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/refractor": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-4.7.0.tgz",
@@ -21071,6 +21127,19 @@
"dev": true,
"optional": true
},
"node_modules/shortid": {
"version": "2.2.16",
"resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.16.tgz",
"integrity": "sha512-Ugt+GIZqvGXCIItnsL+lvFJOiN7RYqlGy7QE41O3YC1xbNSeDGIRO7xg2JJXIAj1cAGnOeC1r7/T9pgrtQbv4g==",
"dependencies": {
"nanoid": "^2.1.0"
}
},
"node_modules/shortid/node_modules/nanoid": {
"version": "2.1.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz",
"integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA=="
},
"node_modules/side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
@@ -21961,6 +22030,20 @@
"node": ">=6"
}
},
"node_modules/strip-indent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.0.0.tgz",
"integrity": "sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==",
"dependencies": {
"min-indent": "^1.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -29091,6 +29174,12 @@
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
"dev": true
},
"@types/shortid": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/shortid/-/shortid-0.0.29.tgz",
"integrity": "sha512-9BCYD9btg2CY4kPcpMQ+vCR8U6V8f/KvixYD5ZbxoWlkhddNF5IeZMVL3p+QFUkg+Hb+kPAG9Jgk4bnnF1v/Fw==",
"dev": true
},
"@types/source-list-map": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
@@ -32174,6 +32263,11 @@
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.0.tgz",
"integrity": "sha512-/6kJlmVv6RDFPqaHC/ZDcU8bblYcoph2dUQ3kB47QqhkUEqXe3VZPELK9BaEMrC73qu+wn0AQ7iSteceN+yuMw=="
},
"detect-indentation": {
"version": "5.20.0",
"resolved": "https://registry.npmjs.org/detect-indentation/-/detect-indentation-5.20.0.tgz",
"integrity": "sha512-nm/f3Pc8ESNfq5WV9x1wSZlzXrBopcjsRLmQahHqBqV8M1XgU3oWhj4xSJuMT5m0I7La7Z4G6983g7bigP6rmg=="
},
"detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -37598,6 +37692,11 @@
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true
},
"min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="
},
"mini-css-extract-plugin": {
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.11.3.tgz",
@@ -40891,6 +40990,22 @@
}
}
},
"redent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz",
"integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==",
"requires": {
"indent-string": "^5.0.0",
"strip-indent": "^4.0.0"
},
"dependencies": {
"indent-string": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
"integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="
}
}
},
"refractor": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-4.7.0.tgz",
@@ -41967,6 +42082,21 @@
"dev": true,
"optional": true
},
"shortid": {
"version": "2.2.16",
"resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.16.tgz",
"integrity": "sha512-Ugt+GIZqvGXCIItnsL+lvFJOiN7RYqlGy7QE41O3YC1xbNSeDGIRO7xg2JJXIAj1cAGnOeC1r7/T9pgrtQbv4g==",
"requires": {
"nanoid": "^2.1.0"
},
"dependencies": {
"nanoid": {
"version": "2.1.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz",
"integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA=="
}
}
},
"side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
@@ -42707,6 +42837,14 @@
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"dev": true
},
"strip-indent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.0.0.tgz",
"integrity": "sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==",
"requires": {
"min-indent": "^1.0.1"
}
},
"strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",

View File

@@ -31,6 +31,7 @@
"@tiptap/extension-underline": "^2.0.0-beta.23",
"@tiptap/starter-kit": "^2.0.0-beta.185",
"detect-indent": "^7.0.0",
"detect-indentation": "^5.20.0",
"emotion-theming": "^10.0.19",
"esm-loader": "^0.1.0",
"highlight.js": "^11.5.1",
@@ -48,7 +49,10 @@
"react-toggle": "^4.1.2",
"reactjs-popup": "^2.0.5",
"rebass": "^4.0.7",
"redent": "^4.0.0",
"refractor": "^4.7.0",
"shortid": "^2.2.16",
"strip-indent": "^4.0.0",
"tinycolor2": "^1.4.2",
"tippy.js": "^6.3.7",
"zustand": "^3.7.2"
@@ -64,6 +68,7 @@
"@types/react-toggle": "^4.0.3",
"@types/rebass": "^4.0.10",
"@types/rebass__forms": "^4.0.6",
"@types/shortid": "^0.0.29",
"@types/tinycolor2": "^1.4.3",
"esm": "^3.2.25",
"isomorphic-fetch": "^3.0.0",

View File

@@ -1,4 +1,4 @@
import { Editor } from "@tiptap/core";
import { Editor, NodeViewRendererProps } from "@tiptap/core";
import { Node, textblockTypeInputRule, mergeAttributes } from "@tiptap/core";
import {
Plugin,
@@ -11,18 +11,22 @@ import { ResolvedPos, Node as ProsemirrorNode } from "prosemirror-model";
import { findParentNodeClosestToPos, ReactNodeViewRenderer } from "../react";
import { CodeblockComponent } from "./component";
import { HighlighterPlugin } from "./highlighter";
import ReactNodeView from "../react/ReactNodeView";
import detectIndent from "detect-indent";
import redent from "redent";
import stripIndent from "strip-indent";
export type IndentationOptions = {
type: "space" | "tab";
length: number;
};
interface Indent {
type: "tab" | "space";
amount: number;
}
export type CodeBlockAttributes = {
indentType: IndentationOptions["type"];
indentType: Indent["type"];
indentLength: number;
language: string;
lines: CodeLine[];
caretPosition?: CaretPosition;
};
export interface CodeBlockOptions {
@@ -67,7 +71,7 @@ declare module "@tiptap/core" {
/**
* Change code block indentation options
*/
changeCodeBlockIndentation: (options: IndentationOptions) => ReturnType;
changeCodeBlockIndentation: (options: Indent) => ReturnType;
};
}
}
@@ -101,6 +105,10 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
addAttributes() {
return {
caretPosition: {
default: undefined,
rendered: false,
},
lines: {
default: [],
rendered: false,
@@ -109,8 +117,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
default: "space",
parseHTML: (element) => {
const indentType = element.dataset.indentType;
if (indentType) return indentType;
return detectIndent(element.innerText).type;
return indentType;
},
renderHTML: (attributes) => {
if (!attributes.indentType) {
@@ -125,8 +132,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
default: 2,
parseHTML: (element) => {
const indentLength = element.dataset.indentLength;
if (indentLength) return indentLength;
return detectIndent(element.innerText).amount;
return indentLength;
},
renderHTML: (attributes) => {
if (!attributes.indentLength) {
@@ -188,7 +194,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return [
"pre",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
["code", {}, 0],
];
},
@@ -223,7 +229,10 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
if (!whitespaceLength) continue;
const indentLength = whitespaceLength;
const indentToken = indent(options.type, indentLength);
const indentToken = indent({
type: options.type,
amount: indentLength,
});
tr.insertText(
indentToken,
@@ -234,7 +243,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
commands.updateAttributes(this.type, {
indentType: options.type,
indentLength: options.length,
indentLength: options.amount,
});
return true;
},
@@ -263,13 +272,12 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
Backspace: () => {
const { empty, $anchor } = this.editor.state.selection;
const isAtStart = $anchor.pos === 1;
if (!empty || $anchor.parent.type.name !== this.name) {
return false;
}
if (isAtStart || !$anchor.parent.textContent.length) {
return this.editor.commands.clearNodes();
return this.editor.commands.deleteNode(this.type);
}
return false;
@@ -285,7 +293,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
const indentation = parseIndentation($from.parent);
const indentation = parseIndentation($from.parent)!;
return (
(this.options.exitOnTripleEnter &&
@@ -359,8 +367,8 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
const indentation = parseIndentation($from.parent);
const indentToken = indent(indentation.type, indentation.length);
const indentation = parseIndentation($from.parent)!;
const indentToken = indent(indentation);
const { lines } = $from.parent.attrs as CodeBlockAttributes;
const selectedLines = getSelectedLines(lines, selection);
@@ -374,7 +382,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
tr.delete(
tr.mapping.map(line.from),
tr.mapping.map(line.from + indentation.length)
tr.mapping.map(line.from + indentation.amount)
);
}
})
@@ -396,8 +404,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
.chain()
.command(({ tr }) =>
withSelection(tr, (tr) => {
const indentation = parseIndentation($from.parent);
const indentToken = indent(indentation.type, indentation.length);
const indentToken = indent(parseIndentation($from.parent)!);
if (selectedLines.length === 1)
return tr.insertText(indentToken, $from.pos);
@@ -443,11 +450,6 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
// dont create a new code block within code blocks
if (this.editor.isActive(this.type.name)) {
return false;
}
const text = event.clipboardData.getData("text/plain");
const vscode = event.clipboardData.getData("vscode-editor-data");
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
@@ -457,22 +459,35 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
const indent = fixIndentation(
text,
parseIndentation(view.state.selection.$from.parent)
);
const { tr } = view.state;
// create an empty code block
tr.replaceSelectionWith(this.type.create({ language }));
// put cursor inside the newly created code block
tr.setSelection(
TextSelection.near(
tr.doc.resolve(Math.max(0, tr.selection.from - 2))
)
// create an empty code block if not already within one
if (!this.editor.isActive(this.type.name)) {
tr.replaceSelectionWith(
this.type.create({
language,
indentType: indent.type,
indentLength: indent.amount,
})
);
}
// // put cursor inside the newly created code block
// tr.setSelection(
// TextSelection.near(
// tr.doc.resolve(Math.max(0, tr.selection.from - 2))
// )
// );
// add text to code block
// strip carriage return chars from text pasted as code
// see: https://github.com/ProseMirror/prosemirror-view/commit/a50a6bcceb4ce52ac8fcc6162488d8875613aacd
tr.insertText(text.replace(/\r\n?/g, "\n"));
tr.insertText(indent.code.replace(/\r\n?/g, "\n"));
// store meta information
// this is useful for other plugins that depends on the paste event
@@ -490,7 +505,23 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
},
addNodeView() {
return ReactNodeViewRenderer(CodeblockComponent);
return ReactNodeView.fromComponent(CodeblockComponent, {
contentDOMFactory: () => {
const content = document.createElement("div");
content.classList.add("node-content-wrapper");
content.style.whiteSpace = "inherit";
// 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.language !== next.language ||
prev.indentType !== next.indentType
);
},
});
},
});
@@ -499,13 +530,15 @@ export type CaretPosition = {
line: number;
selected?: number;
total: number;
from: number;
};
export function toCaretPosition(
lines: CodeLine[],
selection: Selection
selection: Selection,
lines?: CodeLine[]
): CaretPosition | undefined {
const { $from, $to, $head } = selection;
if ($from.parent.type.name !== CodeBlock.name) return;
lines = lines || getLines($from.parent);
for (const line of lines) {
if ($head.pos >= line.from && $head.pos <= line.to) {
@@ -515,6 +548,7 @@ export function toCaretPosition(
column: lineLength - (line.to - $head.pos),
selected: $to.pos - $from.pos,
total: lines.length,
from: line.from,
};
}
}
@@ -545,22 +579,31 @@ function exitOnTripleEnter(editor: Editor, $from: ResolvedPos) {
.run();
}
function indentOnEnter(
editor: Editor,
$from: ResolvedPos,
options: IndentationOptions
) {
function indentOnEnter(editor: Editor, $from: ResolvedPos, options: Indent) {
const { indentation, newline } = getNewline($from, options) || {};
if (!newline) return false;
return editor
.chain()
.insertContent(`${newline}${indentation}`, {
parseOptions: { preserveWhitespace: "full" },
})
.focus()
.run();
}
function getNewline($from: ResolvedPos, options: Indent) {
const { lines } = $from.parent.attrs as CodeBlockAttributes;
const currentLine = getLineAt(lines, $from.pos);
if (!currentLine) return false;
const text = editor.state.doc.textBetween(currentLine.from, currentLine.to);
const text = currentLine.text();
const indentLength = text.length - text.trimStart().length;
const newline = `${NEWLINE}${indent(options.type, indentLength)}`;
return editor.commands.insertContent(newline, {
parseOptions: { preserveWhitespace: "full" },
});
return {
newline: NEWLINE,
indentation: indent({ amount: indentLength, type: options.type }),
};
}
type CodeLine = {
@@ -613,11 +656,13 @@ function getSelectedLines(lines: CodeLine[], selection: Selection) {
);
}
function parseIndentation(node: ProsemirrorNode): IndentationOptions {
function parseIndentation(node: ProsemirrorNode): Indent | undefined {
if (node.type.name !== CodeBlock.name) return undefined;
const { indentType, indentLength } = node.attrs;
return {
type: indentType,
length: parseInt(indentLength),
amount: parseInt(indentLength),
};
}
@@ -629,9 +674,20 @@ function inRange(x: number, a: number, b: number) {
return x >= a && x <= b;
}
function indent(type: IndentationOptions["type"], length: number) {
const char = type === "space" ? " " : "\t";
return char.repeat(length);
function indent(options: Indent) {
const char = options.type === "space" ? " " : "\t";
return char.repeat(options.amount);
}
function compareCaretPosition(
prev: CaretPosition | undefined,
next: CaretPosition | undefined
): boolean {
return (
next === undefined ||
prev?.column !== next?.column ||
prev?.line !== next?.line
);
}
/**
@@ -653,3 +709,15 @@ function withSelection(
);
return true;
}
function fixIndentation(
code: string,
indent?: Indent
): { code: string } & Indent {
const { amount, type = "space" } = indent || detectIndent(code);
const fixed = redent(code, amount, {
includeEmptyLines: false,
indent: type === "space" ? " " : "\t",
});
return { code: stripIndent(fixed), amount, type };
}

View File

@@ -1,6 +1,5 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "../react";
import { useEffect, useRef, useState } from "react";
import { loadLanguage } from "./loader";
import { isLanguageLoaded, loadLanguage } from "./loader";
import { refractor } from "refractor/lib/core";
import "prism-themes/themes/prism-dracula.min.css";
import { Theme } from "@notesnook/theme";
@@ -11,22 +10,18 @@ import { PopupPresenter } from "../../components/menu/menu";
import { Input } from "@rebass/forms";
import { Icon } from "../../toolbar/components/icon";
import { Icons } from "../../toolbar/icons";
import {
CodeBlockAttributes,
toCaretPosition,
CaretPosition,
getLines,
} from "./code-block";
import { Transaction } from "prosemirror-state";
import { CodeBlockAttributes } from "./code-block";
import { ReactComponentProps } from "../react/types";
export function CodeblockComponent(props: NodeViewProps) {
const { editor, updateAttributes, node } = props;
const { language, indentLength, indentType } =
node.attrs as CodeBlockAttributes;
const theme = editor.storage.theme as Theme;
export function CodeblockComponent(
props: ReactComponentProps<CodeBlockAttributes>
) {
const { editor, updateAttributes, node, forwardRef } = props;
const { language, indentLength, indentType, caretPosition } = node?.attrs;
const theme = editor?.storage.theme as Theme;
const [isOpen, setIsOpen] = useState(false);
const [caretPosition, setCaretPosition] = useState<CaretPosition>();
// const [caretPosition, setCaretPosition] = useState<CaretPosition>();
const toolbarRef = useRef<HTMLDivElement>(null);
const languageDefinition = Languages.find(
@@ -35,35 +30,21 @@ export function CodeblockComponent(props: NodeViewProps) {
useEffect(() => {
(async function () {
if (!language || !languageDefinition) {
updateAttributes({ language: null });
if (!language || !languageDefinition || isLanguageLoaded(language))
return;
}
const syntax = await loadLanguage(languageDefinition.filename);
if (!syntax) return;
refractor.register(syntax);
updateAttributes({
language: languageDefinition.filename,
});
})();
}, [language, updateAttributes]);
useEffect(() => {
function onSelectionUpdate({ transaction }: { transaction: Transaction }) {
const position = toCaretPosition(getLines(node), transaction.selection);
setCaretPosition(position);
}
editor.on("selectionUpdate", onSelectionUpdate);
return () => {
editor.off("selectionUpdate", onSelectionUpdate);
};
}, [node]);
}, [language]);
return (
<NodeViewWrapper>
<ThemeProvider theme={theme}>
<Flex
sx={{
@@ -73,9 +54,11 @@ export function CodeblockComponent(props: NodeViewProps) {
}}
>
<Text
ref={forwardRef}
as="pre"
sx={{
"div, span.token, span.line-number-widget": {
"div, span.token, span.line-number-widget, span.line-number::before":
{
fontFamily: "monospace",
fontSize: "code",
whiteSpace: "pre !important",
@@ -92,11 +75,10 @@ export function CodeblockComponent(props: NodeViewProps) {
pb: 1,
}}
spellCheck={false}
>
<NodeViewContent as="code" />
</Text>
/>
<Flex
ref={toolbarRef}
contentEditable={false}
sx={{
bg: "codeBg",
alignItems: "center",
@@ -119,7 +101,7 @@ export function CodeblockComponent(props: NodeViewProps) {
onClick={() => {
editor.commands.changeCodeBlockIndentation({
type: indentType === "space" ? "tab" : "space",
length: indentLength,
amount: indentLength,
});
}}
>
@@ -173,7 +155,6 @@ export function CodeblockComponent(props: NodeViewProps) {
/>
</PopupPresenter>
</ThemeProvider>
</NodeViewWrapper>
);
}

View File

@@ -35,6 +35,9 @@ function parseNodes(
if (node.type === "element" && node.properties)
classes.push(...(node.properties.className as string[]));
// this is required so that even plain text is wrapped in a span
// during highlighting. Without this, Prosemirror's selection acts
// weird for the first highlighted node/span.
else classes.push("token", "text");
if (node.type === "element") {
@@ -57,20 +60,27 @@ function getLineDecoration(
total: number,
isActive: boolean
) {
const maxLength = String(total).length;
const attributes = {
class: `line-number ${isActive ? "active" : ""}`,
"data-line": String(line).padEnd(String(total).length, " "),
"data-line": String(line).padEnd(maxLength, " "),
};
const spec: any = {
line: line,
active: isActive,
total,
from,
};
// Prosemirror has a selection issue with the widget decoration
// on the first line. To work around that we use inline decoration
// for the first line.
if (line === 1) {
if (
line === 1 ||
// Android Composition API (aka the virtual keyboard) doesn't behave well
// with Decoration widgets so we have to resort to inline line numbers.
isAndroid()
) {
return Decoration.inline(from, from + 1, attributes, spec);
}
@@ -85,7 +95,11 @@ function getLineDecoration(
},
{
...spec,
key: `${line}-${isActive ? "active" : "inactive"}`,
// should rerender when any of these change:
// 1. line number
// 2. line active state
// 3. the max length of all lines
key: `${line}-${isActive ? "active" : ""}-${maxLength}`,
}
);
}
@@ -94,9 +108,9 @@ function getDecorations({
doc,
name,
defaultLanguage,
currentLine,
caretPosition,
}: {
currentLine?: number;
caretPosition?: CaretPosition;
doc: ProsemirrorNode;
name: string;
defaultLanguage: string | null | undefined;
@@ -106,10 +120,11 @@ function getDecorations({
findChildren(doc, (node) => node.type.name === name).forEach((block) => {
const code = block.node.textContent;
const { lines } = block.node.attrs as CodeBlockAttributes;
const lines = toCodeLines(code, block.pos);
for (const line of lines || []) {
const lineNumber = line.index + 1;
const isActive = lineNumber === currentLine;
const isActive =
lineNumber === caretPosition?.line && line.from === caretPosition?.from;
const decoration = getLineDecoration(
line.from,
lineNumber,
@@ -124,19 +139,15 @@ function getDecorations({
? getHighlightNodes(refractor.highlight(code, language))
: null;
if (!nodes) return;
let from = block.pos + 1;
parseNodes(nodes).forEach((node) => {
const to = from + node.text.length;
if (node.classes.length) {
const decoration = Decoration.inline(from, to, {
class: node.classes.join(" "),
});
decorations.push(decoration);
}
from = to;
});
});
@@ -177,11 +188,12 @@ export function HighlighterPlugin({
(node) => node.type.name === name
);
const position = toCaretPosition(
getLines(newState.selection.$head.parent),
newState.selection
);
const position = toCaretPosition(newState.selection);
// const isDocChanged =
// transaction.docChanged &&
// // TODO
// !transaction.steps.every((step) => step instanceof ReplaceAroundStep);
// console.log("Selection", transaction.docChanged, isDocChanged);
if (
transaction.docChanged &&
// Apply decorations if:
@@ -209,7 +221,7 @@ export function HighlighterPlugin({
doc: transaction.doc,
name,
defaultLanguage,
currentLine: position?.line,
caretPosition: position,
});
}
@@ -229,23 +241,42 @@ export function HighlighterPlugin({
},
},
appendTransaction: (transactions, _prevState, nextState) => {
appendTransaction: (transactions, prevState, nextState) => {
const tr = nextState.tr;
let modified = false;
if (transactions.some((transaction) => transaction.docChanged)) {
const docChanged = transactions.some(
(transaction) => transaction.docChanged
);
const selectionChanged =
(nextState.selection.$from.parent.type.name === name ||
prevState.selection.$from.parent.type.name === name) &&
prevState.selection.$from.pos !== nextState.selection.$from.pos;
findChildren(nextState.doc, (node) => node.type.name === name).forEach(
(block) => {
const { node, pos } = block;
const attributes = { ...node.attrs };
if (docChanged) {
const lines = toCodeLines(node.textContent, pos);
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
lines,
});
attributes.lines = lines.slice();
}
if (selectionChanged) {
const position = toCaretPosition(
nextState.selection,
docChanged ? toCodeLines(node.textContent, pos) : undefined
);
attributes.caretPosition = position;
}
if (docChanged || selectionChanged) {
tr.setNodeMarkup(pos, node.type, attributes);
modified = true;
}
);
}
);
return modified ? tr : null;
},
@@ -264,8 +295,11 @@ function getActiveLineDecorations(
const lineDecorations = decorations.find(
undefined,
undefined,
({ line, active }) => {
return (position && line === position.line) || active;
({ line, active, from }) => {
const isSame = position
? line === position.line && from === position.from
: false;
return isSame || active;
}
);
@@ -297,3 +331,8 @@ function getActiveLineDecorations(
}
return decorations.add(doc, newDecorations);
}
function isAndroid() {
var ua = navigator.userAgent.toLowerCase();
return ua.indexOf("android") > -1; //&& ua.indexOf("mobile");
}

View File

@@ -1,6 +1,9 @@
import { Syntax } from "refractor";
const loadedLanguages: Record<string, Syntax | undefined> = {};
export function isLanguageLoaded(name: string) {
return !!loadedLanguages[name];
}
export async function loadLanguage(shortName: string) {
if (loadedLanguages[shortName]) return loadedLanguages[shortName];

View File

@@ -0,0 +1,299 @@
import React from "react";
import {
NodeView,
EditorView,
Decoration,
DecorationSource,
} from "prosemirror-view";
import { Node as PMNode } from "prosemirror-model";
import { PortalProviderAPI } from "./ReactNodeViewPortals";
import { EventDispatcher } from "./event-dispatcher";
import {
ReactComponentProps,
ReactNodeViewOptions,
GetPos,
ForwardRef,
ContentDOM,
} from "./types";
import { Editor, NodeViewRendererProps } from "@tiptap/core";
export default class ReactNodeView<P> implements NodeView {
private domRef!: HTMLElement;
private contentDOMWrapper?: Node;
contentDOM: HTMLElement | undefined;
node: PMNode;
constructor(
node: PMNode,
protected readonly editor: Editor,
protected readonly getPos: GetPos,
protected readonly portalProviderAPI: PortalProviderAPI,
protected readonly eventDispatcher: EventDispatcher,
protected readonly options: ReactNodeViewOptions<P>
) {
this.node = node;
}
/**
* This method exists to move initialization logic out of the constructor,
* so object can be initialized properly before calling render first time.
*
* Example:
* Instance properties get added to an object only after super call in
* constructor, which leads to some methods being undefined during the
* first render.
*/
init() {
this.domRef = this.createDomRef();
// this.setDomAttrs(this.node, this.domRef);
const { dom: contentDOMWrapper, contentDOM } = this.getContentDOM() || {
dom: undefined,
contentDOM: undefined,
};
if (this.domRef && contentDOMWrapper) {
this.domRef.appendChild(contentDOMWrapper);
this.contentDOM = contentDOM ? contentDOM : contentDOMWrapper;
this.contentDOMWrapper = contentDOMWrapper || contentDOM;
}
// @see ED-3790
// something gets messed up during mutation processing inside of a
// nodeView if DOM structure has nested plain "div"s, it doesn't see the
// difference between them and it kills the nodeView
this.domRef.classList.add(`${this.node.type.name}-view-content-wrap`);
this.renderReactComponent(() =>
this.render(this.options.props, this.handleRef)
);
return this;
}
private renderReactComponent(
component: () => React.ReactElement<any> | null
) {
if (!this.domRef || !component) {
return;
}
this.portalProviderAPI.render(component, this.domRef!);
}
createDomRef(): HTMLElement {
if (this.options.wrapperFactory) return this.options.wrapperFactory();
if (!this.node.isInline) {
return document.createElement("div");
}
const htmlElement = document.createElement("span");
return htmlElement;
}
getContentDOM(): ContentDOM {
return this.options.contentDOMFactory?.();
}
handleRef = (node: HTMLElement | null) => this._handleRef(node);
private _handleRef(node: HTMLElement | null) {
const contentDOM = this.contentDOMWrapper || this.contentDOM;
// move the contentDOM node inside the inner reference after rendering
if (node && contentDOM && !node.contains(contentDOM)) {
node.appendChild(contentDOM);
}
}
render(
props: P = {} as P,
forwardRef?: ForwardRef
): React.ReactElement<any> | null {
if (!this.options.component) return null;
return (
<this.options.component
{...props}
editor={this.editor}
getPos={this.getPos}
node={this.node}
forwardRef={forwardRef}
updateAttributes={(attr) => this.updateAttributes(attr)}
/>
);
}
private updateAttributes(attributes: any) {
this.editor.commands.command(({ tr }) => {
if (typeof this.getPos === "boolean") return false;
const pos = this.getPos();
tr.setNodeMarkup(pos, undefined, {
...this.node.attrs,
...attributes,
});
return true;
});
}
update(
node: PMNode,
_decorations: readonly Decoration[],
_innerDecorations: DecorationSource
// _innerDecorations?: Array<Decoration>,
// validUpdate: (currentNode: PMNode, newNode: PMNode) => boolean = () => true
) {
// @see https://github.com/ProseMirror/prosemirror/issues/648
const isValidUpdate = this.node.type === node.type; // && validUpdate(this.node, node);
if (!isValidUpdate) {
return false;
}
// if (this.domRef && !this.node.sameMarkup(node)) {
// this.setDomAttrs(node, this.domRef);
// }
// View should not process a re-render if this is false.
// We dont want to destroy the view, so we return true.
if (!this.viewShouldUpdate(node)) {
this.node = node;
return true;
}
this.node = node;
this.renderReactComponent(() =>
this.render(this.options.props, this.handleRef)
);
return true;
}
ignoreMutation(
mutation: MutationRecord | { type: "selection"; target: Element }
) {
if (!this.dom || !this.contentDOM) {
return true;
}
// TODO if (typeof this.options.ignoreMutation === 'function') {
// return this.options.ignoreMutation({ mutation })
// }
// a leaf/atom node is like a black box for ProseMirror
// and should be fully handled by the node view
if (this.node.isLeaf || this.node.isAtom) {
return true;
}
// ProseMirror should handle any selections
if (mutation.type === "selection") {
return false;
}
// try to prevent a bug on mobiles that will break node views on enter
// this is because ProseMirror cant preventDispatch on enter
// this will lead to a re-render of the node view on enter
// see: https://github.com/ueberdosis/tiptap/issues/1214
if (
this.dom.contains(mutation.target) &&
mutation.type === "childList" &&
this.editor.isFocused
) {
const changedNodes = [
...Array.from(mutation.addedNodes),
...Array.from(mutation.removedNodes),
] as HTMLElement[];
// well check if every changed node is contentEditable
// to make sure its probably mutated by ProseMirror
if (changedNodes.every((node) => node.isContentEditable)) {
return false;
}
}
// we will allow mutation contentDOM with attributes
// so we can for example adding classes within our node view
if (this.contentDOM === mutation.target && mutation.type === "attributes") {
return true;
}
// ProseMirror should handle any changes within contentDOM
if (this.contentDOM.contains(mutation.target)) {
return false;
}
return true;
}
viewShouldUpdate(nextNode: PMNode): boolean {
if (this.options.shouldUpdate)
return this.options.shouldUpdate(this.node, nextNode);
return true;
}
/**
* Copies the attributes from a ProseMirror Node to a DOM node.
* @param node The Prosemirror Node from which to source the attributes
*/
setDomAttrs(node: PMNode, element: HTMLElement) {
Object.keys(node.attrs || {}).forEach((attr) => {
element.setAttribute(attr, node.attrs[attr]);
});
}
get dom() {
return this.domRef;
}
destroy() {
if (!this.domRef) {
return;
}
this.portalProviderAPI.remove(this.domRef);
// @ts-ignore NEW PM API
this.domRef = undefined;
this.contentDOM = undefined;
}
static fromComponent<TProps>(
component: React.ComponentType<TProps & ReactComponentProps>,
options?: Omit<ReactNodeViewOptions<TProps>, "component">
) {
return ({ node, getPos, editor }: NodeViewRendererProps) => {
return new ReactNodeView<TProps>(
node,
editor,
getPos,
editor.storage.portalProviderAPI,
editor.storage.eventDispatcher,
{
...options,
component,
}
).init();
};
}
}
function isiOS(): boolean {
return (
[
"iPad Simulator",
"iPhone Simulator",
"iPod Simulator",
"iPad",
"iPhone",
"iPod",
].includes(navigator.platform) ||
// iPad on iOS 13 detection
(navigator.userAgent.includes("Mac") && "ontouchend" in document)
);
}

View File

@@ -0,0 +1,117 @@
import React from "react";
import {
createPortal,
unstable_renderSubtreeIntoContainer,
unmountComponentAtNode,
} from "react-dom";
import { EventDispatcher } from "./event-dispatcher";
export type BasePortalProviderProps = {
render: (
portalProviderAPI: PortalProviderAPI
) => React.ReactChild | JSX.Element | null;
};
export type Portals = Map<HTMLElement, React.ReactChild>;
export type PortalRendererState = {
portals: Portals;
};
type MountedPortal = {
children: () => React.ReactChild | null;
};
export class PortalProviderAPI extends EventDispatcher {
portals: Map<HTMLElement, MountedPortal> = new Map();
context: any;
constructor() {
super();
}
setContext = (context: any) => {
this.context = context;
};
render(
children: () => React.ReactChild | JSX.Element | null,
container: HTMLElement
) {
this.portals.set(container, {
children,
});
let wrappedChildren = children() as JSX.Element;
unstable_renderSubtreeIntoContainer(
this.context,
wrappedChildren,
container
);
}
// TODO: until https://product-fabric.atlassian.net/browse/ED-5013
// we (unfortunately) need to re-render to pass down any updated context.
// selectively do this for nodeviews that opt-in via `hasAnalyticsContext`
forceUpdate() {}
remove(container: HTMLElement) {
this.portals.delete(container);
// There is a race condition that can happen caused by Prosemirror vs React,
// where Prosemirror removes the container from the DOM before React gets
// around to removing the child from the container
// This will throw a NotFoundError: The node to be removed is not a child of this node
// Both Prosemirror and React remove the elements asynchronously, and in edge
// cases Prosemirror beats React
try {
unmountComponentAtNode(container);
} catch (error) {
console.error(error);
}
}
}
export class PortalProvider extends React.Component<BasePortalProviderProps> {
static displayName = "PortalProvider";
portalProviderAPI: PortalProviderAPI;
constructor(props: BasePortalProviderProps) {
super(props);
this.portalProviderAPI = new PortalProviderAPI();
}
render() {
return this.props.render(this.portalProviderAPI);
}
componentDidUpdate() {
this.portalProviderAPI.forceUpdate();
}
}
export class PortalRenderer extends React.Component<
{ portalProviderAPI: PortalProviderAPI },
PortalRendererState
> {
constructor(props: { portalProviderAPI: PortalProviderAPI }) {
super(props);
props.portalProviderAPI.setContext(this);
props.portalProviderAPI.on("update", this.handleUpdate);
this.state = { portals: new Map() };
}
handleUpdate = (portals: Portals) => this.setState({ portals });
render() {
const { portals } = this.state;
return (
<>
{Array.from(portals.entries()).map(([container, children]) =>
createPortal(children, container)
)}
</>
);
}
}

View File

@@ -0,0 +1,230 @@
import React from "react";
import { DecorationSet, EditorView } from "prosemirror-view";
import { Node as PMNode } from "prosemirror-model";
import { Selection, NodeSelection } from "prosemirror-state";
import { PortalProviderAPI } from "./ReactNodeViewPortals";
import {
stateKey as SelectionChangePluginKey,
ReactNodeViewState,
} from "./plugin";
import { EventDispatcher } from "./event-dispatcher";
import { ReactComponentProps, GetPos, ReactNodeViewOptions } from "./types";
import ReactNodeView from "./ReactNodeView";
import { Editor, NodeViewRendererProps } from "@tiptap/core";
/**
* 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 = ReactComponentProps
> extends ReactNodeView<P> {
private oldSelection: Selection;
private selectionChangeState: ReactNodeViewState;
pos: number | undefined;
posEnd: number | undefined;
constructor(
node: PMNode,
editor: Editor,
getPos: GetPos,
portalProviderAPI: PortalProviderAPI,
eventDispatcher: EventDispatcher,
options: ReactNodeViewOptions<P>
) {
super(node, editor, getPos, portalProviderAPI, eventDispatcher, options);
this.updatePos();
this.oldSelection = editor.view.state.selection;
this.selectionChangeState = SelectionChangePluginKey.getState(
this.editor.view.state
);
this.selectionChangeState.subscribe(this.onSelectionChange);
}
/**
* 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) {
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;
}
}
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);
};
static fromComponent<TProps>(
component: React.ComponentType<TProps & ReactComponentProps>,
options?: Omit<ReactNodeViewOptions<TProps>, "component">
) {
return ({ node, getPos, editor }: NodeViewRendererProps) => {
return new SelectionBasedNodeView<TProps>(
node,
editor,
getPos,
editor.storage.portalProviderAPI,
editor.storage.eventDispatcher,
{
...options,
component,
}
).init();
};
}
}

View File

@@ -0,0 +1,64 @@
import { PluginKey } from "prosemirror-state";
export interface Listeners {
[name: string]: Set<Listener>;
}
export type Listener<T = any> = (data: T) => void;
export type Dispatch<T = any> = (
eventName: PluginKey | string,
data: T
) => void;
export class EventDispatcher<T = any> {
private listeners: Listeners = {};
on(event: string, cb: Listener<T>): void {
if (!this.listeners[event]) {
this.listeners[event] = new Set();
}
this.listeners[event].add(cb);
}
off(event: string, cb: Listener<T>): void {
if (!this.listeners[event]) {
return;
}
if (this.listeners[event].has(cb)) {
this.listeners[event].delete(cb);
}
}
emit(event: string, data: T): void {
if (!this.listeners[event]) {
return;
}
this.listeners[event].forEach((cb) => cb(data));
}
destroy(): void {
this.listeners = {};
}
}
/**
* Creates a dispatch function that can be called inside ProseMirror Plugin
* to notify listeners about that plugin's state change.
*/
export function createDispatch<T>(
eventDispatcher: EventDispatcher<T>
): Dispatch<T> {
return (eventName: PluginKey | string, data: T) => {
if (!eventName) {
throw new Error("event name is required!");
}
const event =
typeof eventName === "string"
? eventName
: (eventName as PluginKey & { key: string }).key;
eventDispatcher.emit(event, data);
};
}

View File

@@ -0,0 +1,52 @@
import { Plugin, PluginKey } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
export type StateChangeHandler = (fromPos: number, toPos: number) => any;
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 plugin = 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);
},
};
},
});
const plugins = () => [plugin];
export default plugins;

View File

@@ -0,0 +1,34 @@
import { Editor } from "@tiptap/core";
import { Node as PMNode, Attrs } from "prosemirror-model";
export interface ReactNodeProps {
selected: boolean;
}
export type GetPos = GetPosNode | boolean;
export type GetPosNode = () => number;
export type ForwardRef = (node: HTMLElement | null) => void;
export type ShouldUpdate = (prevNode: PMNode, nextNode: PMNode) => boolean;
export type UpdateAttributes<T> = (attributes: Partial<T>) => void;
export type ContentDOM =
| {
dom: HTMLElement;
contentDOM?: HTMLElement | null | undefined;
}
| undefined;
export type ReactComponentProps<TAttributes = Attrs> = {
getPos: GetPos;
node: PMNode & { attrs: TAttributes };
editor: Editor;
updateAttributes: UpdateAttributes<TAttributes>;
forwardRef?: ForwardRef;
};
export type ReactNodeViewOptions<P> = {
props?: P;
component?: React.ComponentType<P & ReactComponentProps>;
shouldUpdate?: ShouldUpdate;
contentDOMFactory?: () => ContentDOM;
wrapperFactory?: () => HTMLElement;
};

View File

@@ -3,7 +3,7 @@ import Placeholder from "@tiptap/extension-placeholder";
import Underline from "@tiptap/extension-underline";
import { EditorOptions, useEditor } from "./extensions/react";
import StarterKit from "@tiptap/starter-kit";
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { EditorView } from "prosemirror-view";
import Toolbar from "./toolbar";
import TextAlign from "@tiptap/extension-text-align";
@@ -32,6 +32,8 @@ import { SearchReplace } from "./extensions/search-replace";
import { EmbedNode } from "./extensions/embed";
import { CodeBlock } from "./extensions/code-block";
import { ListItem } from "./extensions/list-item";
import { PortalProviderAPI } from "./extensions/react/ReactNodeViewPortals";
import { EventDispatcher } from "./extensions/react/event-dispatcher";
EditorView.prototype.updateState = function updateState(state) {
if (!(this as any).docView) return; // This prevents the matchesNode error on hot reloads
@@ -39,10 +41,20 @@ EditorView.prototype.updateState = function updateState(state) {
};
const useTiptap = (
options: Partial<EditorOptions & AttachmentOptions & { theme: Theme }> = {},
options: Partial<
EditorOptions &
AttachmentOptions & { theme: Theme; portalProviderAPI: PortalProviderAPI }
> = {},
deps?: React.DependencyList
) => {
const { theme, onCreate, onDownloadAttachment, ...restOptions } = options;
const {
theme,
onCreate,
onDownloadAttachment,
portalProviderAPI,
...restOptions
} = options;
const eventDispatcher = useMemo(() => new EventDispatcher(), []);
const defaultOptions = useMemo<Partial<EditorOptions>>(
() => ({
@@ -103,11 +115,15 @@ const useTiptap = (
if (theme) {
editor.storage.theme = theme;
}
if (portalProviderAPI)
editor.storage.portalProviderAPI = portalProviderAPI;
if (eventDispatcher) editor.storage.eventDispatcher = eventDispatcher;
if (onCreate) onCreate({ editor });
},
injectCSS: false,
}),
[theme, onCreate, onDownloadAttachment]
[theme, onCreate, onDownloadAttachment, portalProviderAPI, eventDispatcher]
);
const editor = useEditor({ ...defaultOptions, ...restOptions }, deps);

15
packages/editor/test.js Normal file
View File

@@ -0,0 +1,15 @@
const text = event.clipboardData.getData("text/plain");
const vscode = event.clipboardData.getData("vscode-editor-data");
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
const language = vscodeData?.mode;
if (!text || !language) {
}
const { tr } = view.state;
const indent = detectIndentation(text);
// create an empty code block
tr.replaceSelectionWith(
);