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

View File

@@ -36,10 +36,13 @@ var __values = (this && this.__values) || function(o) {
}; };
import { Node, textblockTypeInputRule, mergeAttributes } from "@tiptap/core"; import { Node, textblockTypeInputRule, mergeAttributes } from "@tiptap/core";
import { Plugin, PluginKey, TextSelection, } from "prosemirror-state"; import { Plugin, PluginKey, TextSelection, } from "prosemirror-state";
import { findParentNodeClosestToPos, ReactNodeViewRenderer } from "../react"; import { findParentNodeClosestToPos } from "../react";
import { CodeblockComponent } from "./component"; import { CodeblockComponent } from "./component";
import { HighlighterPlugin } from "./highlighter"; import { HighlighterPlugin } from "./highlighter";
import ReactNodeView from "../react/ReactNodeView";
import detectIndent from "detect-indent"; import detectIndent from "detect-indent";
import redent from "redent";
import stripIndent from "strip-indent";
export var backtickInputRegex = /^```([a-z]+)?[\s\n]$/; export var backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
export var tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/; export var tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
var ZERO_WIDTH_SPACE = "\u200b"; var ZERO_WIDTH_SPACE = "\u200b";
@@ -63,6 +66,10 @@ export var CodeBlock = Node.create({
addAttributes: function () { addAttributes: function () {
var _this = this; var _this = this;
return { return {
caretPosition: {
default: undefined,
rendered: false,
},
lines: { lines: {
default: [], default: [],
rendered: false, rendered: false,
@@ -71,9 +78,7 @@ export var CodeBlock = Node.create({
default: "space", default: "space",
parseHTML: function (element) { parseHTML: function (element) {
var indentType = element.dataset.indentType; var indentType = element.dataset.indentType;
if (indentType) return indentType;
return indentType;
return detectIndent(element.innerText).type;
}, },
renderHTML: function (attributes) { renderHTML: function (attributes) {
if (!attributes.indentType) { if (!attributes.indentType) {
@@ -88,9 +93,7 @@ export var CodeBlock = Node.create({
default: 2, default: 2,
parseHTML: function (element) { parseHTML: function (element) {
var indentLength = element.dataset.indentLength; var indentLength = element.dataset.indentLength;
if (indentLength) return indentLength;
return indentLength;
return detectIndent(element.innerText).amount;
}, },
renderHTML: function (attributes) { renderHTML: function (attributes) {
if (!attributes.indentLength) { if (!attributes.indentLength) {
@@ -146,7 +149,7 @@ export var CodeBlock = Node.create({
return [ return [
"pre", "pre",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0, ["code", {}, 0],
]; ];
}, },
addCommands: function () { addCommands: function () {
@@ -183,7 +186,10 @@ export var CodeBlock = Node.create({
if (!whitespaceLength) if (!whitespaceLength)
continue; continue;
var indentLength = whitespaceLength; 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)); 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, { commands.updateAttributes(_this.type, {
indentType: options.type, indentType: options.type,
indentLength: options.length, indentLength: options.amount,
}); });
return true; return true;
}; };
@@ -229,7 +235,7 @@ export var CodeBlock = Node.create({
return false; return false;
} }
if (isAtStart || !$anchor.parent.textContent.length) { if (isAtStart || !$anchor.parent.textContent.length) {
return _this.editor.commands.clearNodes(); return _this.editor.commands.deleteNode(_this.type);
} }
return false; return false;
}, },
@@ -301,7 +307,7 @@ export var CodeBlock = Node.create({
return false; return false;
} }
var indentation = parseIndentation($from.parent); var indentation = parseIndentation($from.parent);
var indentToken = indent(indentation.type, indentation.length); var indentToken = indent(indentation);
var lines = $from.parent.attrs.lines; var lines = $from.parent.attrs.lines;
var selectedLines = getSelectedLines(lines, selection); var selectedLines = getSelectedLines(lines, selection);
return editor return editor
@@ -315,7 +321,7 @@ export var CodeBlock = Node.create({
var line = selectedLines_1_1.value; var line = selectedLines_1_1.value;
if (line.text(indentToken.length) !== indentToken) if (line.text(indentToken.length) !== indentToken)
continue; 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 }; } catch (e_2_1) { e_2 = { error: e_2_1 }; }
@@ -345,8 +351,7 @@ export var CodeBlock = Node.create({
var tr = _a.tr; var tr = _a.tr;
return withSelection(tr, function (tr) { return withSelection(tr, function (tr) {
var e_3, _a; var e_3, _a;
var indentation = parseIndentation($from.parent); var indentToken = indent(parseIndentation($from.parent));
var indentToken = indent(indentation.type, indentation.length);
if (selectedLines.length === 1) if (selectedLines.length === 1)
return tr.insertText(indentToken, $from.pos); return tr.insertText(indentToken, $from.pos);
try { try {
@@ -398,10 +403,6 @@ export var CodeBlock = Node.create({
if (!event.clipboardData) { if (!event.clipboardData) {
return false; 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 text = event.clipboardData.getData("text/plain");
var vscode = event.clipboardData.getData("vscode-editor-data"); var vscode = event.clipboardData.getData("vscode-editor-data");
var vscodeData = vscode ? JSON.parse(vscode) : undefined; var vscodeData = vscode ? JSON.parse(vscode) : undefined;
@@ -409,15 +410,26 @@ export var CodeBlock = Node.create({
if (!text || !language) { if (!text || !language) {
return false; return false;
} }
var indent = fixIndentation(text, parseIndentation(view.state.selection.$from.parent));
var tr = view.state.tr; var tr = view.state.tr;
// create an empty code block // create an empty code block if not already within one
tr.replaceSelectionWith(_this.type.create({ language: language })); if (!_this.editor.isActive(_this.type.name)) {
// put cursor inside the newly created code block tr.replaceSelectionWith(_this.type.create({
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(0, tr.selection.from - 2)))); 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 // add text to code block
// strip carriage return chars from text pasted as code // strip carriage return chars from text pasted as code
// see: https://github.com/ProseMirror/prosemirror-view/commit/a50a6bcceb4ce52ac8fcc6162488d8875613aacd // 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 // store meta information
// this is useful for other plugins that depends on the paste event // this is useful for other plugins that depends on the paste event
// like the paste rule plugin // like the paste rule plugin
@@ -431,14 +443,31 @@ export var CodeBlock = Node.create({
]; ];
}, },
addNodeView: function () { 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 e_4, _a;
var $from = selection.$from, $to = selection.$to, $head = selection.$head; var $from = selection.$from, $to = selection.$to, $head = selection.$head;
if ($from.parent.type.name !== CodeBlock.name) if ($from.parent.type.name !== CodeBlock.name)
return; return;
lines = lines || getLines($from.parent);
try { try {
for (var lines_2 = __values(lines), lines_2_1 = lines_2.next(); !lines_2_1.done; lines_2_1 = lines_2.next()) { 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; var line = lines_2_1.value;
@@ -449,6 +478,7 @@ export function toCaretPosition(lines, selection) {
column: lineLength - (line.to - $head.pos), column: lineLength - (line.to - $head.pos),
selected: $to.pos - $from.pos, selected: $to.pos - $from.pos,
total: lines.length, total: lines.length,
from: line.from,
}; };
} }
} }
@@ -483,16 +513,28 @@ function exitOnTripleEnter(editor, $from) {
.run(); .run();
} }
function indentOnEnter(editor, $from, options) { 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 lines = $from.parent.attrs.lines;
var currentLine = getLineAt(lines, $from.pos); var currentLine = getLineAt(lines, $from.pos);
if (!currentLine) if (!currentLine)
return false; return false;
var text = editor.state.doc.textBetween(currentLine.from, currentLine.to); var text = currentLine.text();
var indentLength = text.length - text.trimStart().length; var indentLength = text.length - text.trimStart().length;
var newline = "".concat(NEWLINE).concat(indent(options.type, indentLength)); return {
return editor.commands.insertContent(newline, { newline: NEWLINE,
parseOptions: { preserveWhitespace: "full" }, indentation: indent({ amount: indentLength, type: options.type }),
}); };
} }
export function toCodeLines(code, pos) { export function toCodeLines(code, pos) {
var positions = []; var positions = [];
@@ -533,10 +575,12 @@ function getSelectedLines(lines, selection) {
}); });
} }
function parseIndentation(node) { function parseIndentation(node) {
if (node.type.name !== CodeBlock.name)
return undefined;
var _a = node.attrs, indentType = _a.indentType, indentLength = _a.indentLength; var _a = node.attrs, indentType = _a.indentType, indentLength = _a.indentLength;
return { return {
type: indentType, type: indentType,
length: parseInt(indentLength), amount: parseInt(indentLength),
}; };
} }
function getLineAt(lines, pos) { function getLineAt(lines, pos) {
@@ -545,9 +589,14 @@ function getLineAt(lines, pos) {
function inRange(x, a, b) { function inRange(x, a, b) {
return x >= a && x <= b; return x >= a && x <= b;
} }
function indent(type, length) { function indent(options) {
var char = type === "space" ? " " : "\t"; var char = options.type === "space" ? " " : "\t";
return char.repeat(length); 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 * 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)))); tr.setSelection(new TextSelection(tr.doc.resolve(tr.mapping.map($anchor.pos)), tr.doc.resolve(tr.mapping.map($head.pos))));
return true; 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"; 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; return ar;
}; };
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { NodeViewContent, NodeViewWrapper } from "../react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { loadLanguage } from "./loader"; import { isLanguageLoaded, loadLanguage } from "./loader";
import { refractor } from "refractor/lib/core"; import { refractor } from "refractor/lib/core";
import "prism-themes/themes/prism-dracula.min.css"; import "prism-themes/themes/prism-dracula.min.css";
import { ThemeProvider } from "emotion-theming"; import { ThemeProvider } from "emotion-theming";
@@ -74,13 +73,12 @@ import { PopupPresenter } from "../../components/menu/menu";
import { Input } from "@rebass/forms"; import { Input } from "@rebass/forms";
import { Icon } from "../../toolbar/components/icon"; import { Icon } from "../../toolbar/components/icon";
import { Icons } from "../../toolbar/icons"; import { Icons } from "../../toolbar/icons";
import { toCaretPosition, getLines, } from "./code-block";
export function CodeblockComponent(props) { export function CodeblockComponent(props) {
var editor = props.editor, updateAttributes = props.updateAttributes, node = props.node; var editor = props.editor, updateAttributes = props.updateAttributes, node = props.node, forwardRef = props.forwardRef;
var _a = node.attrs, language = _a.language, indentLength = _a.indentLength, indentType = _a.indentType; 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.storage.theme; 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 _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 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; })); }); 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 () { useEffect(function () {
@@ -90,10 +88,8 @@ export function CodeblockComponent(props) {
return __generator(this, function (_a) { return __generator(this, function (_a) {
switch (_a.label) { switch (_a.label) {
case 0: case 0:
if (!language || !languageDefinition) { if (!language || !languageDefinition || isLanguageLoaded(language))
updateAttributes({ language: null });
return [2 /*return*/]; return [2 /*return*/];
}
return [4 /*yield*/, loadLanguage(languageDefinition.filename)]; return [4 /*yield*/, loadLanguage(languageDefinition.filename)];
case 1: case 1:
syntax = _a.sent(); syntax = _a.sent();
@@ -108,68 +104,57 @@ export function CodeblockComponent(props) {
}); });
}); });
})(); })();
}, [language, updateAttributes]); }, [language]);
useEffect(function () { return (_jsxs(ThemeProvider, __assign({ theme: theme }, { children: [_jsxs(Flex, __assign({ sx: {
function onSelectionUpdate(_a) { flexDirection: "column",
var transaction = _a.transaction; borderRadius: "default",
var position = toCaretPosition(getLines(node), transaction.selection); overflow: "hidden",
setCaretPosition(position); } }, { children: [_jsx(Text, { ref: forwardRef, as: "pre", sx: {
} "div, span.token, span.line-number-widget, span.line-number::before": {
editor.on("selectionUpdate", onSelectionUpdate); fontFamily: "monospace",
return function () { fontSize: "code",
editor.off("selectionUpdate", onSelectionUpdate); whiteSpace: "pre !important",
}; tabSize: 1,
}, [node]); },
return (_jsx(NodeViewWrapper, { children: _jsxs(ThemeProvider, __assign({ theme: theme }, { children: [_jsxs(Flex, __assign({ sx: { position: "relative",
flexDirection: "column", lineHeight: "20px",
borderRadius: "default", bg: "codeBg",
overflow: "hidden", color: "static",
} }, { children: [_jsx(Text, __assign({ as: "pre", sx: { overflowX: "auto",
"div, span.token, span.line-number-widget": { display: "flex",
fontFamily: "monospace", px: 2,
fontSize: "code", pt: 2,
whiteSpace: "pre !important", pb: 1,
tabSize: 1, }, spellCheck: false }), _jsxs(Flex, __assign({ ref: toolbarRef, contentEditable: false, sx: {
}, bg: "codeBg",
position: "relative", alignItems: "center",
lineHeight: "20px", justifyContent: "end",
bg: "codeBg", borderTop: "1px solid var(--codeBorder)",
color: "static", } }, { children: [caretPosition ? (_jsxs(Text, __assign({ variant: "subBody", sx: { mr: 2, color: "codeFg" } }, { children: ["Line ", caretPosition.line, ", Column ", caretPosition.column, " ", caretPosition.selected
overflowX: "auto", ? "(".concat(caretPosition.selected, " selected)")
display: "flex", : ""] }))) : null, _jsx(Button, __assign({ variant: "icon", sx: { p: 1, mr: 1, ":hover": { bg: "codeSelection" } }, title: "Toggle indentation mode", onClick: function () {
px: 2, editor.commands.changeCodeBlockIndentation({
pt: 2, type: indentType === "space" ? "tab" : "space",
pb: 1, amount: indentLength,
}, spellCheck: false }, { children: _jsx(NodeViewContent, { as: "code" }) })), _jsxs(Flex, __assign({ ref: toolbarRef, sx: { });
bg: "codeBg", } }, { children: _jsxs(Text, __assign({ variant: "subBody", sx: { color: "codeFg" } }, { children: [indentType === "space" ? "Spaces" : "Tabs", ": ", indentLength] })) })), _jsx(Button, __assign({ variant: "icon", sx: {
alignItems: "center", p: 1,
justifyContent: "end", mr: 1,
borderTop: "1px solid var(--codeBorder)", bg: isOpen ? "codeSelection" : "transparent",
} }, { children: [caretPosition ? (_jsxs(Text, __assign({ variant: "subBody", sx: { mr: 2, color: "codeFg" } }, { children: ["Line ", caretPosition.line, ", Column ", caretPosition.column, " ", caretPosition.selected ":hover": { bg: "codeSelection" },
? "(".concat(caretPosition.selected, " selected)") }, onClick: function () { return setIsOpen(true); }, title: "Change language" }, { children: _jsx(Text, __assign({ variant: "subBody", spellCheck: false, sx: { color: "codeFg" } }, { children: (languageDefinition === null || languageDefinition === void 0 ? void 0 : languageDefinition.title) || "Plaintext" })) }))] }))] })), _jsx(PopupPresenter, __assign({ isOpen: isOpen, onClose: function () {
: ""] }))) : null, _jsx(Button, __assign({ variant: "icon", sx: { p: 1, mr: 1, ":hover": { bg: "codeSelection" } }, title: "Toggle indentation mode", onClick: function () { setIsOpen(false);
editor.commands.changeCodeBlockIndentation({ editor.commands.focus();
type: indentType === "space" ? "tab" : "space", }, mobile: "sheet", desktop: "menu", options: {
length: indentLength, type: "menu",
}); position: {
} }, { children: _jsxs(Text, __assign({ variant: "subBody", sx: { color: "codeFg" } }, { children: [indentType === "space" ? "Spaces" : "Tabs", ": ", indentLength] })) })), _jsx(Button, __assign({ variant: "icon", sx: { target: toolbarRef.current || undefined,
p: 1, align: "end",
mr: 1, isTargetAbsolute: true,
bg: isOpen ? "codeSelection" : "transparent", location: "top",
":hover": { bg: "codeSelection" }, yOffset: 5,
}, onClick: function () { return setIsOpen(true); }, title: "Change language" }, { children: _jsx(Text, __assign({ variant: "subBody", spellCheck: false, sx: { color: "codeFg" } }, { children: (languageDefinition === null || languageDefinition === void 0 ? void 0 : languageDefinition.title) || "Plaintext" })) }))] }))] })), _jsx(PopupPresenter, __assign({ isOpen: isOpen, onClose: function () { },
setIsOpen(false); } }, { children: _jsx(LanguageSelector, { selectedLanguage: (languageDefinition === null || languageDefinition === void 0 ? void 0 : languageDefinition.filename) || "Plaintext", onLanguageSelected: function (language) { return updateAttributes({ language: language }); } }) }))] })));
editor.commands.focus();
}, mobile: "sheet", desktop: "menu", options: {
type: "menu",
position: {
target: toolbarRef.current || undefined,
align: "end",
isTargetAbsolute: true,
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 }); } }) }))] })) }));
} }
function LanguageSelector(props) { function LanguageSelector(props) {
var onLanguageSelected = props.onLanguageSelected, selectedLanguage = props.selectedLanguage; 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 { Decoration, DecorationSet } from "prosemirror-view";
import { findChildren } from "@tiptap/core"; import { findChildren } from "@tiptap/core";
import { refractor } from "refractor/lib/core"; import { refractor } from "refractor/lib/core";
import { getLines, toCaretPosition, toCodeLines, } from "./code-block"; import { toCaretPosition, toCodeLines, } from "./code-block";
function parseNodes(nodes, className) { function parseNodes(nodes, className) {
if (className === void 0) { className = []; } if (className === void 0) { className = []; }
return nodes.reduce(function (result, node) { return nodes.reduce(function (result, node) {
@@ -58,6 +58,9 @@ function parseNodes(nodes, className) {
var classes = __spreadArray([], __read(className), false); var classes = __spreadArray([], __read(className), false);
if (node.type === "element" && node.properties) if (node.type === "element" && node.properties)
classes.push.apply(classes, __spreadArray([], __read(node.properties.className), false)); 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 else
classes.push("token", "text"); classes.push("token", "text");
if (node.type === "element") { if (node.type === "element") {
@@ -73,19 +76,24 @@ function getHighlightNodes(result) {
return result.children || []; return result.children || [];
} }
function getLineDecoration(from, line, total, isActive) { function getLineDecoration(from, line, total, isActive) {
var maxLength = String(total).length;
var attributes = { var attributes = {
class: "line-number ".concat(isActive ? "active" : ""), class: "line-number ".concat(isActive ? "active" : ""),
"data-line": String(line).padEnd(String(total).length, " "), "data-line": String(line).padEnd(maxLength, " "),
}; };
var spec = { var spec = {
line: line, line: line,
active: isActive, active: isActive,
total: total, total: total,
from: from,
}; };
// Prosemirror has a selection issue with the widget decoration // Prosemirror has a selection issue with the widget decoration
// on the first line. To work around that we use inline decoration // on the first line. To work around that we use inline decoration
// for the first line. // 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.inline(from, from + 1, attributes, spec);
} }
return Decoration.widget(from, function () { return Decoration.widget(from, function () {
@@ -95,21 +103,26 @@ function getLineDecoration(from, line, total, isActive) {
element.classList.add("active"); element.classList.add("active");
element.innerHTML = attributes["data-line"]; element.innerHTML = attributes["data-line"];
return element; 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) { 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 decorations = [];
var languages = refractor.listLanguages(); var languages = refractor.listLanguages();
findChildren(doc, function (node) { return node.type.name === name; }).forEach(function (block) { findChildren(doc, function (node) { return node.type.name === name; }).forEach(function (block) {
var e_1, _a; var e_1, _a;
var code = block.node.textContent; var code = block.node.textContent;
var lines = block.node.attrs.lines; var lines = toCodeLines(code, block.pos);
try { try {
for (var _b = __values(lines || []), _c = _b.next(); !_c.done; _c = _b.next()) { for (var _b = __values(lines || []), _c = _b.next(); !_c.done; _c = _b.next()) {
var line = _c.value; var line = _c.value;
var lineNumber = line.index + 1; 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); var decoration = getLineDecoration(line.from, lineNumber, (lines === null || lines === void 0 ? void 0 : lines.length) || 0, isActive);
decorations.push(decoration); decorations.push(decoration);
} }
@@ -155,7 +168,12 @@ export function HighlighterPlugin(_a) {
var newNodeName = newState.selection.$head.parent.type.name; var newNodeName = newState.selection.$head.parent.type.name;
var oldNodes = findChildren(oldState.doc, function (node) { return node.type.name === 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 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 && if (transaction.docChanged &&
// Apply decorations if: // Apply decorations if:
// selection includes named node, // selection includes named node,
@@ -177,7 +195,7 @@ export function HighlighterPlugin(_a) {
doc: transaction.doc, doc: transaction.doc,
name: name, name: name,
defaultLanguage: defaultLanguage, defaultLanguage: defaultLanguage,
currentLine: position === null || position === void 0 ? void 0 : position.line, caretPosition: position,
}); });
} }
decorationSet = getActiveLineDecorations(transaction.doc, decorationSet, position); decorationSet = getActiveLineDecorations(transaction.doc, decorationSet, position);
@@ -189,17 +207,29 @@ export function HighlighterPlugin(_a) {
return key.getState(state); return key.getState(state);
}, },
}, },
appendTransaction: function (transactions, _prevState, nextState) { appendTransaction: function (transactions, prevState, nextState) {
var tr = nextState.tr; var tr = nextState.tr;
var modified = false; var modified = false;
if (transactions.some(function (transaction) { return transaction.docChanged; })) { var docChanged = transactions.some(function (transaction) { return transaction.docChanged; });
findChildren(nextState.doc, function (node) { return node.type.name === name; }).forEach(function (block) { var selectionChanged = (nextState.selection.$from.parent.type.name === name ||
var node = block.node, pos = block.pos; 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); var lines = toCodeLines(node.textContent, pos);
tr.setNodeMarkup(pos, undefined, __assign(__assign({}, node.attrs), { lines: lines })); 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; modified = true;
}); }
} });
return modified ? tr : null; return modified ? tr : null;
}, },
}); });
@@ -211,8 +241,11 @@ export function HighlighterPlugin(_a) {
function getActiveLineDecorations(doc, decorations, position) { function getActiveLineDecorations(doc, decorations, position) {
var e_2, _a; var e_2, _a;
var lineDecorations = decorations.find(undefined, undefined, function (_a) { var lineDecorations = decorations.find(undefined, undefined, function (_a) {
var line = _a.line, active = _a.active; var line = _a.line, active = _a.active, from = _a.from;
return (position && line === position.line) || active; var isSame = position
? line === position.line && from === position.from
: false;
return isSame || active;
}); });
if (!lineDecorations.length) if (!lineDecorations.length)
return decorations; return decorations;
@@ -241,3 +274,7 @@ function getActiveLineDecorations(doc, decorations, position) {
} }
return decorations.add(doc, newDecorations); 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>; 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 = {}; var loadedLanguages = {};
export function isLanguageLoaded(name) {
return !!loadedLanguages[name];
}
export function loadLanguage(shortName) { export function loadLanguage(shortName) {
return __awaiter(this, void 0, void 0, function () { return __awaiter(this, void 0, void 0, function () {
var url, result; 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 Toolbar from "./toolbar";
import { Theme } from "@notesnook/theme"; import { Theme } from "@notesnook/theme";
import { AttachmentOptions } from "./extensions/attachment"; import { AttachmentOptions } from "./extensions/attachment";
import { PortalProviderAPI } from "./extensions/react/ReactNodeViewPortals";
declare const useTiptap: (options?: Partial<EditorOptions & AttachmentOptions & { declare const useTiptap: (options?: Partial<EditorOptions & AttachmentOptions & {
theme: Theme; theme: Theme;
portalProviderAPI: PortalProviderAPI;
}>, deps?: React.DependencyList) => import("./extensions/react").Editor | null; }>, deps?: React.DependencyList) => import("./extensions/react").Editor | null;
export { useTiptap, Toolbar }; export { useTiptap, Toolbar };
export * from "./extensions/react"; export * from "./extensions/react";

View File

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

View File

@@ -35,6 +35,7 @@
"@tiptap/extension-underline": "^2.0.0-beta.23", "@tiptap/extension-underline": "^2.0.0-beta.23",
"@tiptap/starter-kit": "^2.0.0-beta.185", "@tiptap/starter-kit": "^2.0.0-beta.185",
"detect-indent": "^7.0.0", "detect-indent": "^7.0.0",
"detect-indentation": "^5.20.0",
"emotion-theming": "^10.0.19", "emotion-theming": "^10.0.19",
"esm-loader": "^0.1.0", "esm-loader": "^0.1.0",
"highlight.js": "^11.5.1", "highlight.js": "^11.5.1",
@@ -52,7 +53,10 @@
"react-toggle": "^4.1.2", "react-toggle": "^4.1.2",
"reactjs-popup": "^2.0.5", "reactjs-popup": "^2.0.5",
"rebass": "^4.0.7", "rebass": "^4.0.7",
"redent": "^4.0.0",
"refractor": "^4.7.0", "refractor": "^4.7.0",
"shortid": "^2.2.16",
"strip-indent": "^4.0.0",
"tinycolor2": "^1.4.2", "tinycolor2": "^1.4.2",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"zustand": "^3.7.2" "zustand": "^3.7.2"
@@ -68,6 +72,7 @@
"@types/react-toggle": "^4.0.3", "@types/react-toggle": "^4.0.3",
"@types/rebass": "^4.0.10", "@types/rebass": "^4.0.10",
"@types/rebass__forms": "^4.0.6", "@types/rebass__forms": "^4.0.6",
"@types/shortid": "^0.0.29",
"@types/tinycolor2": "^1.4.3", "@types/tinycolor2": "^1.4.3",
"esm": "^3.2.25", "esm": "^3.2.25",
"isomorphic-fetch": "^3.0.0", "isomorphic-fetch": "^3.0.0",
@@ -4795,6 +4800,12 @@
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
"dev": true "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": { "node_modules/@types/source-list-map": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
@@ -8641,6 +8652,17 @@
"node": ">=12.20" "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": { "node_modules/detect-newline": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -15709,6 +15731,14 @@
"node": ">=6" "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": { "node_modules/mini-css-extract-plugin": {
"version": "0.11.3", "version": "0.11.3",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.11.3.tgz", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.11.3.tgz",
@@ -19725,6 +19755,32 @@
"node": "*" "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": { "node_modules/refractor": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-4.7.0.tgz", "resolved": "https://registry.npmjs.org/refractor/-/refractor-4.7.0.tgz",
@@ -21071,6 +21127,19 @@
"dev": true, "dev": true,
"optional": 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": { "node_modules/side-channel": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
@@ -21961,6 +22030,20 @@
"node": ">=6" "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": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "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==", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
"dev": true "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": { "@types/source-list-map": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz", "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", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.0.tgz",
"integrity": "sha512-/6kJlmVv6RDFPqaHC/ZDcU8bblYcoph2dUQ3kB47QqhkUEqXe3VZPELK9BaEMrC73qu+wn0AQ7iSteceN+yuMw==" "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": { "detect-newline": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -37598,6 +37692,11 @@
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true "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": { "mini-css-extract-plugin": {
"version": "0.11.3", "version": "0.11.3",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.11.3.tgz", "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": { "refractor": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-4.7.0.tgz", "resolved": "https://registry.npmjs.org/refractor/-/refractor-4.7.0.tgz",
@@ -41967,6 +42082,21 @@
"dev": true, "dev": true,
"optional": 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": { "side-channel": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
@@ -42707,6 +42837,14 @@
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"dev": true "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": { "strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "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/extension-underline": "^2.0.0-beta.23",
"@tiptap/starter-kit": "^2.0.0-beta.185", "@tiptap/starter-kit": "^2.0.0-beta.185",
"detect-indent": "^7.0.0", "detect-indent": "^7.0.0",
"detect-indentation": "^5.20.0",
"emotion-theming": "^10.0.19", "emotion-theming": "^10.0.19",
"esm-loader": "^0.1.0", "esm-loader": "^0.1.0",
"highlight.js": "^11.5.1", "highlight.js": "^11.5.1",
@@ -48,7 +49,10 @@
"react-toggle": "^4.1.2", "react-toggle": "^4.1.2",
"reactjs-popup": "^2.0.5", "reactjs-popup": "^2.0.5",
"rebass": "^4.0.7", "rebass": "^4.0.7",
"redent": "^4.0.0",
"refractor": "^4.7.0", "refractor": "^4.7.0",
"shortid": "^2.2.16",
"strip-indent": "^4.0.0",
"tinycolor2": "^1.4.2", "tinycolor2": "^1.4.2",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"zustand": "^3.7.2" "zustand": "^3.7.2"
@@ -64,6 +68,7 @@
"@types/react-toggle": "^4.0.3", "@types/react-toggle": "^4.0.3",
"@types/rebass": "^4.0.10", "@types/rebass": "^4.0.10",
"@types/rebass__forms": "^4.0.6", "@types/rebass__forms": "^4.0.6",
"@types/shortid": "^0.0.29",
"@types/tinycolor2": "^1.4.3", "@types/tinycolor2": "^1.4.3",
"esm": "^3.2.25", "esm": "^3.2.25",
"isomorphic-fetch": "^3.0.0", "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 { Node, textblockTypeInputRule, mergeAttributes } from "@tiptap/core";
import { import {
Plugin, Plugin,
@@ -11,18 +11,22 @@ import { ResolvedPos, Node as ProsemirrorNode } from "prosemirror-model";
import { findParentNodeClosestToPos, ReactNodeViewRenderer } from "../react"; import { findParentNodeClosestToPos, ReactNodeViewRenderer } from "../react";
import { CodeblockComponent } from "./component"; import { CodeblockComponent } from "./component";
import { HighlighterPlugin } from "./highlighter"; import { HighlighterPlugin } from "./highlighter";
import ReactNodeView from "../react/ReactNodeView";
import detectIndent from "detect-indent"; import detectIndent from "detect-indent";
import redent from "redent";
import stripIndent from "strip-indent";
export type IndentationOptions = { interface Indent {
type: "space" | "tab"; type: "tab" | "space";
length: number; amount: number;
}; }
export type CodeBlockAttributes = { export type CodeBlockAttributes = {
indentType: IndentationOptions["type"]; indentType: Indent["type"];
indentLength: number; indentLength: number;
language: string; language: string;
lines: CodeLine[]; lines: CodeLine[];
caretPosition?: CaretPosition;
}; };
export interface CodeBlockOptions { export interface CodeBlockOptions {
@@ -67,7 +71,7 @@ declare module "@tiptap/core" {
/** /**
* Change code block indentation options * Change code block indentation options
*/ */
changeCodeBlockIndentation: (options: IndentationOptions) => ReturnType; changeCodeBlockIndentation: (options: Indent) => ReturnType;
}; };
} }
} }
@@ -101,6 +105,10 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
addAttributes() { addAttributes() {
return { return {
caretPosition: {
default: undefined,
rendered: false,
},
lines: { lines: {
default: [], default: [],
rendered: false, rendered: false,
@@ -109,8 +117,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
default: "space", default: "space",
parseHTML: (element) => { parseHTML: (element) => {
const indentType = element.dataset.indentType; const indentType = element.dataset.indentType;
if (indentType) return indentType; return indentType;
return detectIndent(element.innerText).type;
}, },
renderHTML: (attributes) => { renderHTML: (attributes) => {
if (!attributes.indentType) { if (!attributes.indentType) {
@@ -125,8 +132,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
default: 2, default: 2,
parseHTML: (element) => { parseHTML: (element) => {
const indentLength = element.dataset.indentLength; const indentLength = element.dataset.indentLength;
if (indentLength) return indentLength; return indentLength;
return detectIndent(element.innerText).amount;
}, },
renderHTML: (attributes) => { renderHTML: (attributes) => {
if (!attributes.indentLength) { if (!attributes.indentLength) {
@@ -188,7 +194,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return [ return [
"pre", "pre",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0, ["code", {}, 0],
]; ];
}, },
@@ -223,7 +229,10 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
if (!whitespaceLength) continue; if (!whitespaceLength) continue;
const indentLength = whitespaceLength; const indentLength = whitespaceLength;
const indentToken = indent(options.type, indentLength); const indentToken = indent({
type: options.type,
amount: indentLength,
});
tr.insertText( tr.insertText(
indentToken, indentToken,
@@ -234,7 +243,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
commands.updateAttributes(this.type, { commands.updateAttributes(this.type, {
indentType: options.type, indentType: options.type,
indentLength: options.length, indentLength: options.amount,
}); });
return true; return true;
}, },
@@ -263,13 +272,12 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
Backspace: () => { Backspace: () => {
const { empty, $anchor } = this.editor.state.selection; const { empty, $anchor } = this.editor.state.selection;
const isAtStart = $anchor.pos === 1; const isAtStart = $anchor.pos === 1;
if (!empty || $anchor.parent.type.name !== this.name) { if (!empty || $anchor.parent.type.name !== this.name) {
return false; return false;
} }
if (isAtStart || !$anchor.parent.textContent.length) { if (isAtStart || !$anchor.parent.textContent.length) {
return this.editor.commands.clearNodes(); return this.editor.commands.deleteNode(this.type);
} }
return false; return false;
@@ -285,7 +293,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false; return false;
} }
const indentation = parseIndentation($from.parent); const indentation = parseIndentation($from.parent)!;
return ( return (
(this.options.exitOnTripleEnter && (this.options.exitOnTripleEnter &&
@@ -359,8 +367,8 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false; return false;
} }
const indentation = parseIndentation($from.parent); const indentation = parseIndentation($from.parent)!;
const indentToken = indent(indentation.type, indentation.length); const indentToken = indent(indentation);
const { lines } = $from.parent.attrs as CodeBlockAttributes; const { lines } = $from.parent.attrs as CodeBlockAttributes;
const selectedLines = getSelectedLines(lines, selection); const selectedLines = getSelectedLines(lines, selection);
@@ -374,7 +382,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
tr.delete( tr.delete(
tr.mapping.map(line.from), 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() .chain()
.command(({ tr }) => .command(({ tr }) =>
withSelection(tr, (tr) => { withSelection(tr, (tr) => {
const indentation = parseIndentation($from.parent); const indentToken = indent(parseIndentation($from.parent)!);
const indentToken = indent(indentation.type, indentation.length);
if (selectedLines.length === 1) if (selectedLines.length === 1)
return tr.insertText(indentToken, $from.pos); return tr.insertText(indentToken, $from.pos);
@@ -443,11 +450,6 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false; 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 text = event.clipboardData.getData("text/plain");
const vscode = event.clipboardData.getData("vscode-editor-data"); const vscode = event.clipboardData.getData("vscode-editor-data");
const vscodeData = vscode ? JSON.parse(vscode) : undefined; const vscodeData = vscode ? JSON.parse(vscode) : undefined;
@@ -457,22 +459,35 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false; return false;
} }
const indent = fixIndentation(
text,
parseIndentation(view.state.selection.$from.parent)
);
const { tr } = view.state; const { tr } = view.state;
// create an empty code block // create an empty code block if not already within one
tr.replaceSelectionWith(this.type.create({ language })); 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 // // put cursor inside the newly created code block
tr.setSelection( // tr.setSelection(
TextSelection.near( // TextSelection.near(
tr.doc.resolve(Math.max(0, tr.selection.from - 2)) // tr.doc.resolve(Math.max(0, tr.selection.from - 2))
) // )
); // );
// add text to code block // add text to code block
// strip carriage return chars from text pasted as code // strip carriage return chars from text pasted as code
// see: https://github.com/ProseMirror/prosemirror-view/commit/a50a6bcceb4ce52ac8fcc6162488d8875613aacd // 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 // store meta information
// this is useful for other plugins that depends on the paste event // this is useful for other plugins that depends on the paste event
@@ -490,7 +505,23 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
}, },
addNodeView() { 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; line: number;
selected?: number; selected?: number;
total: number; total: number;
from: number;
}; };
export function toCaretPosition( export function toCaretPosition(
lines: CodeLine[], selection: Selection,
selection: Selection lines?: CodeLine[]
): CaretPosition | undefined { ): CaretPosition | undefined {
const { $from, $to, $head } = selection; const { $from, $to, $head } = selection;
if ($from.parent.type.name !== CodeBlock.name) return; if ($from.parent.type.name !== CodeBlock.name) return;
lines = lines || getLines($from.parent);
for (const line of lines) { for (const line of lines) {
if ($head.pos >= line.from && $head.pos <= line.to) { if ($head.pos >= line.from && $head.pos <= line.to) {
@@ -515,6 +548,7 @@ export function toCaretPosition(
column: lineLength - (line.to - $head.pos), column: lineLength - (line.to - $head.pos),
selected: $to.pos - $from.pos, selected: $to.pos - $from.pos,
total: lines.length, total: lines.length,
from: line.from,
}; };
} }
} }
@@ -545,22 +579,31 @@ function exitOnTripleEnter(editor: Editor, $from: ResolvedPos) {
.run(); .run();
} }
function indentOnEnter( function indentOnEnter(editor: Editor, $from: ResolvedPos, options: Indent) {
editor: Editor, const { indentation, newline } = getNewline($from, options) || {};
$from: ResolvedPos, if (!newline) return false;
options: IndentationOptions
) { 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 { lines } = $from.parent.attrs as CodeBlockAttributes;
const currentLine = getLineAt(lines, $from.pos); const currentLine = getLineAt(lines, $from.pos);
if (!currentLine) return false; 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 indentLength = text.length - text.trimStart().length;
const newline = `${NEWLINE}${indent(options.type, indentLength)}`; return {
return editor.commands.insertContent(newline, { newline: NEWLINE,
parseOptions: { preserveWhitespace: "full" }, indentation: indent({ amount: indentLength, type: options.type }),
}); };
} }
type CodeLine = { 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; const { indentType, indentLength } = node.attrs;
return { return {
type: indentType, 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; return x >= a && x <= b;
} }
function indent(type: IndentationOptions["type"], length: number) { function indent(options: Indent) {
const char = type === "space" ? " " : "\t"; const char = options.type === "space" ? " " : "\t";
return char.repeat(length); 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; 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 { useEffect, useRef, useState } from "react";
import { loadLanguage } from "./loader"; import { isLanguageLoaded, loadLanguage } from "./loader";
import { refractor } from "refractor/lib/core"; import { refractor } from "refractor/lib/core";
import "prism-themes/themes/prism-dracula.min.css"; import "prism-themes/themes/prism-dracula.min.css";
import { Theme } from "@notesnook/theme"; import { Theme } from "@notesnook/theme";
@@ -11,22 +10,18 @@ import { PopupPresenter } from "../../components/menu/menu";
import { Input } from "@rebass/forms"; import { Input } from "@rebass/forms";
import { Icon } from "../../toolbar/components/icon"; import { Icon } from "../../toolbar/components/icon";
import { Icons } from "../../toolbar/icons"; import { Icons } from "../../toolbar/icons";
import { import { CodeBlockAttributes } from "./code-block";
CodeBlockAttributes, import { ReactComponentProps } from "../react/types";
toCaretPosition,
CaretPosition,
getLines,
} from "./code-block";
import { Transaction } from "prosemirror-state";
export function CodeblockComponent(props: NodeViewProps) { export function CodeblockComponent(
const { editor, updateAttributes, node } = props; props: ReactComponentProps<CodeBlockAttributes>
const { language, indentLength, indentType } = ) {
node.attrs as CodeBlockAttributes; const { editor, updateAttributes, node, forwardRef } = props;
const theme = editor.storage.theme as Theme; const { language, indentLength, indentType, caretPosition } = node?.attrs;
const theme = editor?.storage.theme as Theme;
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [caretPosition, setCaretPosition] = useState<CaretPosition>(); // const [caretPosition, setCaretPosition] = useState<CaretPosition>();
const toolbarRef = useRef<HTMLDivElement>(null); const toolbarRef = useRef<HTMLDivElement>(null);
const languageDefinition = Languages.find( const languageDefinition = Languages.find(
@@ -35,145 +30,131 @@ export function CodeblockComponent(props: NodeViewProps) {
useEffect(() => { useEffect(() => {
(async function () { (async function () {
if (!language || !languageDefinition) { if (!language || !languageDefinition || isLanguageLoaded(language))
updateAttributes({ language: null });
return; return;
}
const syntax = await loadLanguage(languageDefinition.filename); const syntax = await loadLanguage(languageDefinition.filename);
if (!syntax) return; if (!syntax) return;
refractor.register(syntax); refractor.register(syntax);
updateAttributes({ updateAttributes({
language: languageDefinition.filename, language: languageDefinition.filename,
}); });
})(); })();
}, [language, updateAttributes]); }, [language]);
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]);
return ( return (
<NodeViewWrapper> <ThemeProvider theme={theme}>
<ThemeProvider theme={theme}> <Flex
<Flex sx={{
flexDirection: "column",
borderRadius: "default",
overflow: "hidden",
}}
>
<Text
ref={forwardRef}
as="pre"
sx={{ sx={{
flexDirection: "column", "div, span.token, span.line-number-widget, span.line-number::before":
borderRadius: "default", {
overflow: "hidden",
}}
>
<Text
as="pre"
sx={{
"div, span.token, span.line-number-widget": {
fontFamily: "monospace", fontFamily: "monospace",
fontSize: "code", fontSize: "code",
whiteSpace: "pre !important", whiteSpace: "pre !important",
tabSize: 1, tabSize: 1,
}, },
position: "relative", position: "relative",
lineHeight: "20px", lineHeight: "20px",
bg: "codeBg", bg: "codeBg",
color: "static", color: "static",
overflowX: "auto", overflowX: "auto",
display: "flex", display: "flex",
px: 2, px: 2,
pt: 2, pt: 2,
pb: 1, pb: 1,
}}
spellCheck={false}
>
<NodeViewContent as="code" />
</Text>
<Flex
ref={toolbarRef}
sx={{
bg: "codeBg",
alignItems: "center",
justifyContent: "end",
borderTop: "1px solid var(--codeBorder)",
}}
>
{caretPosition ? (
<Text variant={"subBody"} sx={{ mr: 2, color: "codeFg" }}>
Line {caretPosition.line}, Column {caretPosition.column}{" "}
{caretPosition.selected
? `(${caretPosition.selected} selected)`
: ""}
</Text>
) : null}
<Button
variant={"icon"}
sx={{ p: 1, mr: 1, ":hover": { bg: "codeSelection" } }}
title="Toggle indentation mode"
onClick={() => {
editor.commands.changeCodeBlockIndentation({
type: indentType === "space" ? "tab" : "space",
length: indentLength,
});
}}
>
<Text variant={"subBody"} sx={{ color: "codeFg" }}>
{indentType === "space" ? "Spaces" : "Tabs"}: {indentLength}
</Text>
</Button>
<Button
variant={"icon"}
sx={{
p: 1,
mr: 1,
bg: isOpen ? "codeSelection" : "transparent",
":hover": { bg: "codeSelection" },
}}
onClick={() => setIsOpen(true)}
title="Change language"
>
<Text
variant={"subBody"}
spellCheck={false}
sx={{ color: "codeFg" }}
>
{languageDefinition?.title || "Plaintext"}
</Text>
</Button>
</Flex>
</Flex>
<PopupPresenter
isOpen={isOpen}
onClose={() => {
setIsOpen(false);
editor.commands.focus();
}} }}
mobile="sheet" spellCheck={false}
desktop="menu" />
options={{ <Flex
type: "menu", ref={toolbarRef}
position: { contentEditable={false}
target: toolbarRef.current || undefined, sx={{
align: "end", bg: "codeBg",
isTargetAbsolute: true, alignItems: "center",
location: "top", justifyContent: "end",
yOffset: 5, borderTop: "1px solid var(--codeBorder)",
},
}} }}
> >
<LanguageSelector {caretPosition ? (
selectedLanguage={languageDefinition?.filename || "Plaintext"} <Text variant={"subBody"} sx={{ mr: 2, color: "codeFg" }}>
onLanguageSelected={(language) => updateAttributes({ language })} Line {caretPosition.line}, Column {caretPosition.column}{" "}
/> {caretPosition.selected
</PopupPresenter> ? `(${caretPosition.selected} selected)`
</ThemeProvider> : ""}
</NodeViewWrapper> </Text>
) : null}
<Button
variant={"icon"}
sx={{ p: 1, mr: 1, ":hover": { bg: "codeSelection" } }}
title="Toggle indentation mode"
onClick={() => {
editor.commands.changeCodeBlockIndentation({
type: indentType === "space" ? "tab" : "space",
amount: indentLength,
});
}}
>
<Text variant={"subBody"} sx={{ color: "codeFg" }}>
{indentType === "space" ? "Spaces" : "Tabs"}: {indentLength}
</Text>
</Button>
<Button
variant={"icon"}
sx={{
p: 1,
mr: 1,
bg: isOpen ? "codeSelection" : "transparent",
":hover": { bg: "codeSelection" },
}}
onClick={() => setIsOpen(true)}
title="Change language"
>
<Text
variant={"subBody"}
spellCheck={false}
sx={{ color: "codeFg" }}
>
{languageDefinition?.title || "Plaintext"}
</Text>
</Button>
</Flex>
</Flex>
<PopupPresenter
isOpen={isOpen}
onClose={() => {
setIsOpen(false);
editor.commands.focus();
}}
mobile="sheet"
desktop="menu"
options={{
type: "menu",
position: {
target: toolbarRef.current || undefined,
align: "end",
isTargetAbsolute: true,
location: "top",
yOffset: 5,
},
}}
>
<LanguageSelector
selectedLanguage={languageDefinition?.filename || "Plaintext"}
onLanguageSelected={(language) => updateAttributes({ language })}
/>
</PopupPresenter>
</ThemeProvider>
); );
} }

View File

@@ -35,6 +35,9 @@ function parseNodes(
if (node.type === "element" && node.properties) if (node.type === "element" && node.properties)
classes.push(...(node.properties.className as string[])); 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"); else classes.push("token", "text");
if (node.type === "element") { if (node.type === "element") {
@@ -57,20 +60,27 @@ function getLineDecoration(
total: number, total: number,
isActive: boolean isActive: boolean
) { ) {
const maxLength = String(total).length;
const attributes = { const attributes = {
class: `line-number ${isActive ? "active" : ""}`, class: `line-number ${isActive ? "active" : ""}`,
"data-line": String(line).padEnd(String(total).length, " "), "data-line": String(line).padEnd(maxLength, " "),
}; };
const spec: any = { const spec: any = {
line: line, line: line,
active: isActive, active: isActive,
total, total,
from,
}; };
// Prosemirror has a selection issue with the widget decoration // Prosemirror has a selection issue with the widget decoration
// on the first line. To work around that we use inline decoration // on the first line. To work around that we use inline decoration
// for the first line. // 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.inline(from, from + 1, attributes, spec);
} }
@@ -85,7 +95,11 @@ function getLineDecoration(
}, },
{ {
...spec, ...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, doc,
name, name,
defaultLanguage, defaultLanguage,
currentLine, caretPosition,
}: { }: {
currentLine?: number; caretPosition?: CaretPosition;
doc: ProsemirrorNode; doc: ProsemirrorNode;
name: string; name: string;
defaultLanguage: string | null | undefined; defaultLanguage: string | null | undefined;
@@ -106,10 +120,11 @@ function getDecorations({
findChildren(doc, (node) => node.type.name === name).forEach((block) => { findChildren(doc, (node) => node.type.name === name).forEach((block) => {
const code = block.node.textContent; const code = block.node.textContent;
const { lines } = block.node.attrs as CodeBlockAttributes; const lines = toCodeLines(code, block.pos);
for (const line of lines || []) { for (const line of lines || []) {
const lineNumber = line.index + 1; const lineNumber = line.index + 1;
const isActive = lineNumber === currentLine; const isActive =
lineNumber === caretPosition?.line && line.from === caretPosition?.from;
const decoration = getLineDecoration( const decoration = getLineDecoration(
line.from, line.from,
lineNumber, lineNumber,
@@ -124,19 +139,15 @@ function getDecorations({
? getHighlightNodes(refractor.highlight(code, language)) ? getHighlightNodes(refractor.highlight(code, language))
: null; : null;
if (!nodes) return; if (!nodes) return;
let from = block.pos + 1; let from = block.pos + 1;
parseNodes(nodes).forEach((node) => { parseNodes(nodes).forEach((node) => {
const to = from + node.text.length; const to = from + node.text.length;
if (node.classes.length) { if (node.classes.length) {
const decoration = Decoration.inline(from, to, { const decoration = Decoration.inline(from, to, {
class: node.classes.join(" "), class: node.classes.join(" "),
}); });
decorations.push(decoration); decorations.push(decoration);
} }
from = to; from = to;
}); });
}); });
@@ -177,11 +188,12 @@ export function HighlighterPlugin({
(node) => node.type.name === name (node) => node.type.name === name
); );
const position = toCaretPosition( const position = toCaretPosition(newState.selection);
getLines(newState.selection.$head.parent), // const isDocChanged =
newState.selection // transaction.docChanged &&
); // // TODO
// !transaction.steps.every((step) => step instanceof ReplaceAroundStep);
// console.log("Selection", transaction.docChanged, isDocChanged);
if ( if (
transaction.docChanged && transaction.docChanged &&
// Apply decorations if: // Apply decorations if:
@@ -209,7 +221,7 @@ export function HighlighterPlugin({
doc: transaction.doc, doc: transaction.doc,
name, name,
defaultLanguage, 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; const tr = nextState.tr;
let modified = false; let modified = false;
if (transactions.some((transaction) => transaction.docChanged)) { const docChanged = transactions.some(
findChildren(nextState.doc, (node) => node.type.name === name).forEach( (transaction) => transaction.docChanged
(block) => { );
const { node, pos } = block; 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); const lines = toCodeLines(node.textContent, pos);
tr.setNodeMarkup(pos, undefined, { attributes.lines = lines.slice();
...node.attrs, }
lines,
}); 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; modified = true;
} }
); }
} );
return modified ? tr : null; return modified ? tr : null;
}, },
@@ -264,8 +295,11 @@ function getActiveLineDecorations(
const lineDecorations = decorations.find( const lineDecorations = decorations.find(
undefined, undefined,
undefined, undefined,
({ line, active }) => { ({ line, active, from }) => {
return (position && line === position.line) || active; const isSame = position
? line === position.line && from === position.from
: false;
return isSame || active;
} }
); );
@@ -297,3 +331,8 @@ function getActiveLineDecorations(
} }
return decorations.add(doc, newDecorations); 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"; import { Syntax } from "refractor";
const loadedLanguages: Record<string, Syntax | undefined> = {}; const loadedLanguages: Record<string, Syntax | undefined> = {};
export function isLanguageLoaded(name: string) {
return !!loadedLanguages[name];
}
export async function loadLanguage(shortName: string) { export async function loadLanguage(shortName: string) {
if (loadedLanguages[shortName]) return loadedLanguages[shortName]; 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 Underline from "@tiptap/extension-underline";
import { EditorOptions, useEditor } from "./extensions/react"; import { EditorOptions, useEditor } from "./extensions/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import { useMemo } from "react"; import { useCallback, useMemo } from "react";
import { EditorView } from "prosemirror-view"; import { EditorView } from "prosemirror-view";
import Toolbar from "./toolbar"; import Toolbar from "./toolbar";
import TextAlign from "@tiptap/extension-text-align"; import TextAlign from "@tiptap/extension-text-align";
@@ -32,6 +32,8 @@ import { SearchReplace } from "./extensions/search-replace";
import { EmbedNode } from "./extensions/embed"; import { EmbedNode } from "./extensions/embed";
import { CodeBlock } from "./extensions/code-block"; import { CodeBlock } from "./extensions/code-block";
import { ListItem } from "./extensions/list-item"; 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) { EditorView.prototype.updateState = function updateState(state) {
if (!(this as any).docView) return; // This prevents the matchesNode error on hot reloads 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 = ( const useTiptap = (
options: Partial<EditorOptions & AttachmentOptions & { theme: Theme }> = {}, options: Partial<
EditorOptions &
AttachmentOptions & { theme: Theme; portalProviderAPI: PortalProviderAPI }
> = {},
deps?: React.DependencyList 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>>( const defaultOptions = useMemo<Partial<EditorOptions>>(
() => ({ () => ({
@@ -103,11 +115,15 @@ const useTiptap = (
if (theme) { if (theme) {
editor.storage.theme = theme; editor.storage.theme = theme;
} }
if (portalProviderAPI)
editor.storage.portalProviderAPI = portalProviderAPI;
if (eventDispatcher) editor.storage.eventDispatcher = eventDispatcher;
if (onCreate) onCreate({ editor }); if (onCreate) onCreate({ editor });
}, },
injectCSS: false, injectCSS: false,
}), }),
[theme, onCreate, onDownloadAttachment] [theme, onCreate, onDownloadAttachment, portalProviderAPI, eventDispatcher]
); );
const editor = useEditor({ ...defaultOptions, ...restOptions }, deps); 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(
);