mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-16 11:47:54 +01:00
feat: fix codeblock for mobile
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
import { Node } from "@tiptap/core";
|
||||
import { Selection } from "prosemirror-state";
|
||||
import { Node as ProsemirrorNode } from "prosemirror-model";
|
||||
export declare type IndentationOptions = {
|
||||
type: "space" | "tab";
|
||||
length: number;
|
||||
};
|
||||
interface Indent {
|
||||
type: "tab" | "space";
|
||||
amount: number;
|
||||
}
|
||||
export declare type CodeBlockAttributes = {
|
||||
indentType: IndentationOptions["type"];
|
||||
indentType: Indent["type"];
|
||||
indentLength: number;
|
||||
language: string;
|
||||
lines: CodeLine[];
|
||||
caretPosition?: CaretPosition;
|
||||
};
|
||||
export interface CodeBlockOptions {
|
||||
/**
|
||||
@@ -55,7 +56,7 @@ declare module "@tiptap/core" {
|
||||
/**
|
||||
* Change code block indentation options
|
||||
*/
|
||||
changeCodeBlockIndentation: (options: IndentationOptions) => ReturnType;
|
||||
changeCodeBlockIndentation: (options: Indent) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -67,8 +68,9 @@ export declare type CaretPosition = {
|
||||
line: number;
|
||||
selected?: number;
|
||||
total: number;
|
||||
from: number;
|
||||
};
|
||||
export declare function toCaretPosition(lines: CodeLine[], selection: Selection): CaretPosition | undefined;
|
||||
export declare function toCaretPosition(selection: Selection, lines?: CodeLine[]): CaretPosition | undefined;
|
||||
export declare function getLines(node: ProsemirrorNode): CodeLine[];
|
||||
declare type CodeLine = {
|
||||
index: number;
|
||||
|
||||
@@ -36,10 +36,13 @@ var __values = (this && this.__values) || function(o) {
|
||||
};
|
||||
import { Node, textblockTypeInputRule, mergeAttributes } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, TextSelection, } from "prosemirror-state";
|
||||
import { findParentNodeClosestToPos, ReactNodeViewRenderer } from "../react";
|
||||
import { findParentNodeClosestToPos } from "../react";
|
||||
import { CodeblockComponent } from "./component";
|
||||
import { HighlighterPlugin } from "./highlighter";
|
||||
import ReactNodeView from "../react/ReactNodeView";
|
||||
import detectIndent from "detect-indent";
|
||||
import redent from "redent";
|
||||
import stripIndent from "strip-indent";
|
||||
export var backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
|
||||
export var tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
|
||||
var ZERO_WIDTH_SPACE = "\u200b";
|
||||
@@ -63,6 +66,10 @@ export var CodeBlock = Node.create({
|
||||
addAttributes: function () {
|
||||
var _this = this;
|
||||
return {
|
||||
caretPosition: {
|
||||
default: undefined,
|
||||
rendered: false,
|
||||
},
|
||||
lines: {
|
||||
default: [],
|
||||
rendered: false,
|
||||
@@ -71,9 +78,7 @@ export var CodeBlock = Node.create({
|
||||
default: "space",
|
||||
parseHTML: function (element) {
|
||||
var indentType = element.dataset.indentType;
|
||||
if (indentType)
|
||||
return indentType;
|
||||
return detectIndent(element.innerText).type;
|
||||
return indentType;
|
||||
},
|
||||
renderHTML: function (attributes) {
|
||||
if (!attributes.indentType) {
|
||||
@@ -88,9 +93,7 @@ export var CodeBlock = Node.create({
|
||||
default: 2,
|
||||
parseHTML: function (element) {
|
||||
var indentLength = element.dataset.indentLength;
|
||||
if (indentLength)
|
||||
return indentLength;
|
||||
return detectIndent(element.innerText).amount;
|
||||
return indentLength;
|
||||
},
|
||||
renderHTML: function (attributes) {
|
||||
if (!attributes.indentLength) {
|
||||
@@ -146,7 +149,7 @@ export var CodeBlock = Node.create({
|
||||
return [
|
||||
"pre",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
0,
|
||||
["code", {}, 0],
|
||||
];
|
||||
},
|
||||
addCommands: function () {
|
||||
@@ -183,7 +186,10 @@ export var CodeBlock = Node.create({
|
||||
if (!whitespaceLength)
|
||||
continue;
|
||||
var indentLength = whitespaceLength;
|
||||
var indentToken = indent(options.type, indentLength);
|
||||
var indentToken = indent({
|
||||
type: options.type,
|
||||
amount: indentLength,
|
||||
});
|
||||
tr.insertText(indentToken, tr.mapping.map(line.from), tr.mapping.map(line.from + whitespaceLength));
|
||||
}
|
||||
}
|
||||
@@ -196,7 +202,7 @@ export var CodeBlock = Node.create({
|
||||
}
|
||||
commands.updateAttributes(_this.type, {
|
||||
indentType: options.type,
|
||||
indentLength: options.length,
|
||||
indentLength: options.amount,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
@@ -229,7 +235,7 @@ export var CodeBlock = Node.create({
|
||||
return false;
|
||||
}
|
||||
if (isAtStart || !$anchor.parent.textContent.length) {
|
||||
return _this.editor.commands.clearNodes();
|
||||
return _this.editor.commands.deleteNode(_this.type);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
@@ -301,7 +307,7 @@ export var CodeBlock = Node.create({
|
||||
return false;
|
||||
}
|
||||
var indentation = parseIndentation($from.parent);
|
||||
var indentToken = indent(indentation.type, indentation.length);
|
||||
var indentToken = indent(indentation);
|
||||
var lines = $from.parent.attrs.lines;
|
||||
var selectedLines = getSelectedLines(lines, selection);
|
||||
return editor
|
||||
@@ -315,7 +321,7 @@ export var CodeBlock = Node.create({
|
||||
var line = selectedLines_1_1.value;
|
||||
if (line.text(indentToken.length) !== indentToken)
|
||||
continue;
|
||||
tr.delete(tr.mapping.map(line.from), tr.mapping.map(line.from + indentation.length));
|
||||
tr.delete(tr.mapping.map(line.from), tr.mapping.map(line.from + indentation.amount));
|
||||
}
|
||||
}
|
||||
catch (e_2_1) { e_2 = { error: e_2_1 }; }
|
||||
@@ -345,8 +351,7 @@ export var CodeBlock = Node.create({
|
||||
var tr = _a.tr;
|
||||
return withSelection(tr, function (tr) {
|
||||
var e_3, _a;
|
||||
var indentation = parseIndentation($from.parent);
|
||||
var indentToken = indent(indentation.type, indentation.length);
|
||||
var indentToken = indent(parseIndentation($from.parent));
|
||||
if (selectedLines.length === 1)
|
||||
return tr.insertText(indentToken, $from.pos);
|
||||
try {
|
||||
@@ -398,10 +403,6 @@ export var CodeBlock = Node.create({
|
||||
if (!event.clipboardData) {
|
||||
return false;
|
||||
}
|
||||
// don’t create a new code block within code blocks
|
||||
if (_this.editor.isActive(_this.type.name)) {
|
||||
return false;
|
||||
}
|
||||
var text = event.clipboardData.getData("text/plain");
|
||||
var vscode = event.clipboardData.getData("vscode-editor-data");
|
||||
var vscodeData = vscode ? JSON.parse(vscode) : undefined;
|
||||
@@ -409,15 +410,26 @@ export var CodeBlock = Node.create({
|
||||
if (!text || !language) {
|
||||
return false;
|
||||
}
|
||||
var indent = fixIndentation(text, parseIndentation(view.state.selection.$from.parent));
|
||||
var tr = view.state.tr;
|
||||
// create an empty code block
|
||||
tr.replaceSelectionWith(_this.type.create({ language: language }));
|
||||
// put cursor inside the newly created code block
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(0, tr.selection.from - 2))));
|
||||
// create an empty code block if not already within one
|
||||
if (!_this.editor.isActive(_this.type.name)) {
|
||||
tr.replaceSelectionWith(_this.type.create({
|
||||
language: language,
|
||||
indentType: indent.type,
|
||||
indentLength: indent.amount,
|
||||
}));
|
||||
}
|
||||
// // put cursor inside the newly created code block
|
||||
// tr.setSelection(
|
||||
// TextSelection.near(
|
||||
// tr.doc.resolve(Math.max(0, tr.selection.from - 2))
|
||||
// )
|
||||
// );
|
||||
// add text to code block
|
||||
// strip carriage return chars from text pasted as code
|
||||
// see: https://github.com/ProseMirror/prosemirror-view/commit/a50a6bcceb4ce52ac8fcc6162488d8875613aacd
|
||||
tr.insertText(text.replace(/\r\n?/g, "\n"));
|
||||
tr.insertText(indent.code.replace(/\r\n?/g, "\n"));
|
||||
// store meta information
|
||||
// this is useful for other plugins that depends on the paste event
|
||||
// like the paste rule plugin
|
||||
@@ -431,14 +443,31 @@ export var CodeBlock = Node.create({
|
||||
];
|
||||
},
|
||||
addNodeView: function () {
|
||||
return ReactNodeViewRenderer(CodeblockComponent);
|
||||
return ReactNodeView.fromComponent(CodeblockComponent, {
|
||||
contentDOMFactory: function () {
|
||||
var content = document.createElement("div");
|
||||
content.classList.add("node-content-wrapper");
|
||||
content.style.whiteSpace = "inherit";
|
||||
// caret is not visible if content element width is 0px
|
||||
content.style.minWidth = "20px";
|
||||
return { dom: content };
|
||||
},
|
||||
shouldUpdate: function (_a, _b) {
|
||||
var prev = _a.attrs;
|
||||
var next = _b.attrs;
|
||||
return (compareCaretPosition(prev.caretPosition, next.caretPosition) ||
|
||||
prev.language !== next.language ||
|
||||
prev.indentType !== next.indentType);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
export function toCaretPosition(lines, selection) {
|
||||
export function toCaretPosition(selection, lines) {
|
||||
var e_4, _a;
|
||||
var $from = selection.$from, $to = selection.$to, $head = selection.$head;
|
||||
if ($from.parent.type.name !== CodeBlock.name)
|
||||
return;
|
||||
lines = lines || getLines($from.parent);
|
||||
try {
|
||||
for (var lines_2 = __values(lines), lines_2_1 = lines_2.next(); !lines_2_1.done; lines_2_1 = lines_2.next()) {
|
||||
var line = lines_2_1.value;
|
||||
@@ -449,6 +478,7 @@ export function toCaretPosition(lines, selection) {
|
||||
column: lineLength - (line.to - $head.pos),
|
||||
selected: $to.pos - $from.pos,
|
||||
total: lines.length,
|
||||
from: line.from,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -483,16 +513,28 @@ function exitOnTripleEnter(editor, $from) {
|
||||
.run();
|
||||
}
|
||||
function indentOnEnter(editor, $from, options) {
|
||||
var _a = getNewline($from, options) || {}, indentation = _a.indentation, newline = _a.newline;
|
||||
if (!newline)
|
||||
return false;
|
||||
return editor
|
||||
.chain()
|
||||
.insertContent("".concat(newline).concat(indentation), {
|
||||
parseOptions: { preserveWhitespace: "full" },
|
||||
})
|
||||
.focus()
|
||||
.run();
|
||||
}
|
||||
function getNewline($from, options) {
|
||||
var lines = $from.parent.attrs.lines;
|
||||
var currentLine = getLineAt(lines, $from.pos);
|
||||
if (!currentLine)
|
||||
return false;
|
||||
var text = editor.state.doc.textBetween(currentLine.from, currentLine.to);
|
||||
var text = currentLine.text();
|
||||
var indentLength = text.length - text.trimStart().length;
|
||||
var newline = "".concat(NEWLINE).concat(indent(options.type, indentLength));
|
||||
return editor.commands.insertContent(newline, {
|
||||
parseOptions: { preserveWhitespace: "full" },
|
||||
});
|
||||
return {
|
||||
newline: NEWLINE,
|
||||
indentation: indent({ amount: indentLength, type: options.type }),
|
||||
};
|
||||
}
|
||||
export function toCodeLines(code, pos) {
|
||||
var positions = [];
|
||||
@@ -533,10 +575,12 @@ function getSelectedLines(lines, selection) {
|
||||
});
|
||||
}
|
||||
function parseIndentation(node) {
|
||||
if (node.type.name !== CodeBlock.name)
|
||||
return undefined;
|
||||
var _a = node.attrs, indentType = _a.indentType, indentLength = _a.indentLength;
|
||||
return {
|
||||
type: indentType,
|
||||
length: parseInt(indentLength),
|
||||
amount: parseInt(indentLength),
|
||||
};
|
||||
}
|
||||
function getLineAt(lines, pos) {
|
||||
@@ -545,9 +589,14 @@ function getLineAt(lines, pos) {
|
||||
function inRange(x, a, b) {
|
||||
return x >= a && x <= b;
|
||||
}
|
||||
function indent(type, length) {
|
||||
var char = type === "space" ? " " : "\t";
|
||||
return char.repeat(length);
|
||||
function indent(options) {
|
||||
var char = options.type === "space" ? " " : "\t";
|
||||
return char.repeat(options.amount);
|
||||
}
|
||||
function compareCaretPosition(prev, next) {
|
||||
return (next === undefined ||
|
||||
(prev === null || prev === void 0 ? void 0 : prev.column) !== (next === null || next === void 0 ? void 0 : next.column) ||
|
||||
(prev === null || prev === void 0 ? void 0 : prev.line) !== (next === null || next === void 0 ? void 0 : next.line));
|
||||
}
|
||||
/**
|
||||
* Persist selection between transaction steps
|
||||
@@ -558,3 +607,11 @@ function withSelection(tr, callback) {
|
||||
tr.setSelection(new TextSelection(tr.doc.resolve(tr.mapping.map($anchor.pos)), tr.doc.resolve(tr.mapping.map($head.pos))));
|
||||
return true;
|
||||
}
|
||||
function fixIndentation(code, indent) {
|
||||
var _a = indent || detectIndent(code), amount = _a.amount, _b = _a.type, type = _b === void 0 ? "space" : _b;
|
||||
var fixed = redent(code, amount, {
|
||||
includeEmptyLines: false,
|
||||
indent: type === "space" ? " " : "\t",
|
||||
});
|
||||
return { code: stripIndent(fixed), amount: amount, type: type };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NodeViewProps } from "../react";
|
||||
import "prism-themes/themes/prism-dracula.min.css";
|
||||
export declare function CodeblockComponent(props: NodeViewProps): JSX.Element;
|
||||
import { CodeBlockAttributes } from "./code-block";
|
||||
import { ReactComponentProps } from "../react/types";
|
||||
export declare function CodeblockComponent(props: ReactComponentProps<CodeBlockAttributes>): JSX.Element;
|
||||
|
||||
@@ -62,9 +62,8 @@ var __read = (this && this.__read) || function (o, n) {
|
||||
return ar;
|
||||
};
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { NodeViewContent, NodeViewWrapper } from "../react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { loadLanguage } from "./loader";
|
||||
import { isLanguageLoaded, loadLanguage } from "./loader";
|
||||
import { refractor } from "refractor/lib/core";
|
||||
import "prism-themes/themes/prism-dracula.min.css";
|
||||
import { ThemeProvider } from "emotion-theming";
|
||||
@@ -74,13 +73,12 @@ import { PopupPresenter } from "../../components/menu/menu";
|
||||
import { Input } from "@rebass/forms";
|
||||
import { Icon } from "../../toolbar/components/icon";
|
||||
import { Icons } from "../../toolbar/icons";
|
||||
import { toCaretPosition, getLines, } from "./code-block";
|
||||
export function CodeblockComponent(props) {
|
||||
var editor = props.editor, updateAttributes = props.updateAttributes, node = props.node;
|
||||
var _a = node.attrs, language = _a.language, indentLength = _a.indentLength, indentType = _a.indentType;
|
||||
var theme = editor.storage.theme;
|
||||
var editor = props.editor, updateAttributes = props.updateAttributes, node = props.node, forwardRef = props.forwardRef;
|
||||
var _a = node === null || node === void 0 ? void 0 : node.attrs, language = _a.language, indentLength = _a.indentLength, indentType = _a.indentType, caretPosition = _a.caretPosition;
|
||||
var theme = editor === null || editor === void 0 ? void 0 : editor.storage.theme;
|
||||
var _b = __read(useState(false), 2), isOpen = _b[0], setIsOpen = _b[1];
|
||||
var _c = __read(useState(), 2), caretPosition = _c[0], setCaretPosition = _c[1];
|
||||
// const [caretPosition, setCaretPosition] = useState<CaretPosition>();
|
||||
var toolbarRef = useRef(null);
|
||||
var languageDefinition = Languages.find(function (l) { var _a; return l.filename === language || ((_a = l.alias) === null || _a === void 0 ? void 0 : _a.some(function (a) { return a === language; })); });
|
||||
useEffect(function () {
|
||||
@@ -90,10 +88,8 @@ export function CodeblockComponent(props) {
|
||||
return __generator(this, function (_a) {
|
||||
switch (_a.label) {
|
||||
case 0:
|
||||
if (!language || !languageDefinition) {
|
||||
updateAttributes({ language: null });
|
||||
if (!language || !languageDefinition || isLanguageLoaded(language))
|
||||
return [2 /*return*/];
|
||||
}
|
||||
return [4 /*yield*/, loadLanguage(languageDefinition.filename)];
|
||||
case 1:
|
||||
syntax = _a.sent();
|
||||
@@ -108,68 +104,57 @@ export function CodeblockComponent(props) {
|
||||
});
|
||||
});
|
||||
})();
|
||||
}, [language, updateAttributes]);
|
||||
useEffect(function () {
|
||||
function onSelectionUpdate(_a) {
|
||||
var transaction = _a.transaction;
|
||||
var position = toCaretPosition(getLines(node), transaction.selection);
|
||||
setCaretPosition(position);
|
||||
}
|
||||
editor.on("selectionUpdate", onSelectionUpdate);
|
||||
return function () {
|
||||
editor.off("selectionUpdate", onSelectionUpdate);
|
||||
};
|
||||
}, [node]);
|
||||
return (_jsx(NodeViewWrapper, { children: _jsxs(ThemeProvider, __assign({ theme: theme }, { children: [_jsxs(Flex, __assign({ sx: {
|
||||
flexDirection: "column",
|
||||
borderRadius: "default",
|
||||
overflow: "hidden",
|
||||
} }, { children: [_jsx(Text, __assign({ as: "pre", sx: {
|
||||
"div, span.token, span.line-number-widget": {
|
||||
fontFamily: "monospace",
|
||||
fontSize: "code",
|
||||
whiteSpace: "pre !important",
|
||||
tabSize: 1,
|
||||
},
|
||||
position: "relative",
|
||||
lineHeight: "20px",
|
||||
bg: "codeBg",
|
||||
color: "static",
|
||||
overflowX: "auto",
|
||||
display: "flex",
|
||||
px: 2,
|
||||
pt: 2,
|
||||
pb: 1,
|
||||
}, spellCheck: false }, { children: _jsx(NodeViewContent, { as: "code" }) })), _jsxs(Flex, __assign({ ref: toolbarRef, sx: {
|
||||
bg: "codeBg",
|
||||
alignItems: "center",
|
||||
justifyContent: "end",
|
||||
borderTop: "1px solid var(--codeBorder)",
|
||||
} }, { children: [caretPosition ? (_jsxs(Text, __assign({ variant: "subBody", sx: { mr: 2, color: "codeFg" } }, { children: ["Line ", caretPosition.line, ", Column ", caretPosition.column, " ", caretPosition.selected
|
||||
? "(".concat(caretPosition.selected, " selected)")
|
||||
: ""] }))) : null, _jsx(Button, __assign({ variant: "icon", sx: { p: 1, mr: 1, ":hover": { bg: "codeSelection" } }, title: "Toggle indentation mode", onClick: function () {
|
||||
editor.commands.changeCodeBlockIndentation({
|
||||
type: indentType === "space" ? "tab" : "space",
|
||||
length: indentLength,
|
||||
});
|
||||
} }, { children: _jsxs(Text, __assign({ variant: "subBody", sx: { color: "codeFg" } }, { children: [indentType === "space" ? "Spaces" : "Tabs", ": ", indentLength] })) })), _jsx(Button, __assign({ variant: "icon", sx: {
|
||||
p: 1,
|
||||
mr: 1,
|
||||
bg: isOpen ? "codeSelection" : "transparent",
|
||||
":hover": { bg: "codeSelection" },
|
||||
}, 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);
|
||||
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 }); } }) }))] })) }));
|
||||
}, [language]);
|
||||
return (_jsxs(ThemeProvider, __assign({ theme: theme }, { children: [_jsxs(Flex, __assign({ sx: {
|
||||
flexDirection: "column",
|
||||
borderRadius: "default",
|
||||
overflow: "hidden",
|
||||
} }, { children: [_jsx(Text, { ref: forwardRef, as: "pre", sx: {
|
||||
"div, span.token, span.line-number-widget, span.line-number::before": {
|
||||
fontFamily: "monospace",
|
||||
fontSize: "code",
|
||||
whiteSpace: "pre !important",
|
||||
tabSize: 1,
|
||||
},
|
||||
position: "relative",
|
||||
lineHeight: "20px",
|
||||
bg: "codeBg",
|
||||
color: "static",
|
||||
overflowX: "auto",
|
||||
display: "flex",
|
||||
px: 2,
|
||||
pt: 2,
|
||||
pb: 1,
|
||||
}, spellCheck: false }), _jsxs(Flex, __assign({ ref: toolbarRef, contentEditable: false, sx: {
|
||||
bg: "codeBg",
|
||||
alignItems: "center",
|
||||
justifyContent: "end",
|
||||
borderTop: "1px solid var(--codeBorder)",
|
||||
} }, { children: [caretPosition ? (_jsxs(Text, __assign({ variant: "subBody", sx: { mr: 2, color: "codeFg" } }, { children: ["Line ", caretPosition.line, ", Column ", caretPosition.column, " ", caretPosition.selected
|
||||
? "(".concat(caretPosition.selected, " selected)")
|
||||
: ""] }))) : null, _jsx(Button, __assign({ variant: "icon", sx: { p: 1, mr: 1, ":hover": { bg: "codeSelection" } }, title: "Toggle indentation mode", onClick: function () {
|
||||
editor.commands.changeCodeBlockIndentation({
|
||||
type: indentType === "space" ? "tab" : "space",
|
||||
amount: indentLength,
|
||||
});
|
||||
} }, { children: _jsxs(Text, __assign({ variant: "subBody", sx: { color: "codeFg" } }, { children: [indentType === "space" ? "Spaces" : "Tabs", ": ", indentLength] })) })), _jsx(Button, __assign({ variant: "icon", sx: {
|
||||
p: 1,
|
||||
mr: 1,
|
||||
bg: isOpen ? "codeSelection" : "transparent",
|
||||
":hover": { bg: "codeSelection" },
|
||||
}, 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);
|
||||
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) {
|
||||
var onLanguageSelected = props.onLanguageSelected, selectedLanguage = props.selectedLanguage;
|
||||
|
||||
@@ -49,7 +49,7 @@ import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import { findChildren } from "@tiptap/core";
|
||||
import { refractor } from "refractor/lib/core";
|
||||
import { getLines, toCaretPosition, toCodeLines, } from "./code-block";
|
||||
import { toCaretPosition, toCodeLines, } from "./code-block";
|
||||
function parseNodes(nodes, className) {
|
||||
if (className === void 0) { className = []; }
|
||||
return nodes.reduce(function (result, node) {
|
||||
@@ -58,6 +58,9 @@ function parseNodes(nodes, className) {
|
||||
var classes = __spreadArray([], __read(className), false);
|
||||
if (node.type === "element" && node.properties)
|
||||
classes.push.apply(classes, __spreadArray([], __read(node.properties.className), false));
|
||||
// this is required so that even plain text is wrapped in a span
|
||||
// during highlighting. Without this, Prosemirror's selection acts
|
||||
// weird for the first highlighted node/span.
|
||||
else
|
||||
classes.push("token", "text");
|
||||
if (node.type === "element") {
|
||||
@@ -73,19 +76,24 @@ function getHighlightNodes(result) {
|
||||
return result.children || [];
|
||||
}
|
||||
function getLineDecoration(from, line, total, isActive) {
|
||||
var maxLength = String(total).length;
|
||||
var attributes = {
|
||||
class: "line-number ".concat(isActive ? "active" : ""),
|
||||
"data-line": String(line).padEnd(String(total).length, " "),
|
||||
"data-line": String(line).padEnd(maxLength, " "),
|
||||
};
|
||||
var spec = {
|
||||
line: line,
|
||||
active: isActive,
|
||||
total: total,
|
||||
from: from,
|
||||
};
|
||||
// Prosemirror has a selection issue with the widget decoration
|
||||
// on the first line. To work around that we use inline decoration
|
||||
// for the first line.
|
||||
if (line === 1) {
|
||||
if (line === 1 ||
|
||||
// Android Composition API (aka the virtual keyboard) doesn't behave well
|
||||
// with Decoration widgets so we have to resort to inline line numbers.
|
||||
isAndroid()) {
|
||||
return Decoration.inline(from, from + 1, attributes, spec);
|
||||
}
|
||||
return Decoration.widget(from, function () {
|
||||
@@ -95,21 +103,26 @@ function getLineDecoration(from, line, total, isActive) {
|
||||
element.classList.add("active");
|
||||
element.innerHTML = attributes["data-line"];
|
||||
return element;
|
||||
}, __assign(__assign({}, spec), { key: "".concat(line, "-").concat(isActive ? "active" : "inactive") }));
|
||||
}, __assign(__assign({}, spec), {
|
||||
// should rerender when any of these change:
|
||||
// 1. line number
|
||||
// 2. line active state
|
||||
// 3. the max length of all lines
|
||||
key: "".concat(line, "-").concat(isActive ? "active" : "", "-").concat(maxLength) }));
|
||||
}
|
||||
function getDecorations(_a) {
|
||||
var doc = _a.doc, name = _a.name, defaultLanguage = _a.defaultLanguage, currentLine = _a.currentLine;
|
||||
var doc = _a.doc, name = _a.name, defaultLanguage = _a.defaultLanguage, caretPosition = _a.caretPosition;
|
||||
var decorations = [];
|
||||
var languages = refractor.listLanguages();
|
||||
findChildren(doc, function (node) { return node.type.name === name; }).forEach(function (block) {
|
||||
var e_1, _a;
|
||||
var code = block.node.textContent;
|
||||
var lines = block.node.attrs.lines;
|
||||
var lines = toCodeLines(code, block.pos);
|
||||
try {
|
||||
for (var _b = __values(lines || []), _c = _b.next(); !_c.done; _c = _b.next()) {
|
||||
var line = _c.value;
|
||||
var lineNumber = line.index + 1;
|
||||
var isActive = lineNumber === currentLine;
|
||||
var isActive = lineNumber === (caretPosition === null || caretPosition === void 0 ? void 0 : caretPosition.line) && line.from === (caretPosition === null || caretPosition === void 0 ? void 0 : caretPosition.from);
|
||||
var decoration = getLineDecoration(line.from, lineNumber, (lines === null || lines === void 0 ? void 0 : lines.length) || 0, isActive);
|
||||
decorations.push(decoration);
|
||||
}
|
||||
@@ -155,7 +168,12 @@ export function HighlighterPlugin(_a) {
|
||||
var newNodeName = newState.selection.$head.parent.type.name;
|
||||
var oldNodes = findChildren(oldState.doc, function (node) { return node.type.name === name; });
|
||||
var newNodes = findChildren(newState.doc, function (node) { return node.type.name === name; });
|
||||
var position = toCaretPosition(getLines(newState.selection.$head.parent), newState.selection);
|
||||
var position = toCaretPosition(newState.selection);
|
||||
// const isDocChanged =
|
||||
// transaction.docChanged &&
|
||||
// // TODO
|
||||
// !transaction.steps.every((step) => step instanceof ReplaceAroundStep);
|
||||
// console.log("Selection", transaction.docChanged, isDocChanged);
|
||||
if (transaction.docChanged &&
|
||||
// Apply decorations if:
|
||||
// selection includes named node,
|
||||
@@ -177,7 +195,7 @@ export function HighlighterPlugin(_a) {
|
||||
doc: transaction.doc,
|
||||
name: name,
|
||||
defaultLanguage: defaultLanguage,
|
||||
currentLine: position === null || position === void 0 ? void 0 : position.line,
|
||||
caretPosition: position,
|
||||
});
|
||||
}
|
||||
decorationSet = getActiveLineDecorations(transaction.doc, decorationSet, position);
|
||||
@@ -189,17 +207,29 @@ export function HighlighterPlugin(_a) {
|
||||
return key.getState(state);
|
||||
},
|
||||
},
|
||||
appendTransaction: function (transactions, _prevState, nextState) {
|
||||
appendTransaction: function (transactions, prevState, nextState) {
|
||||
var tr = nextState.tr;
|
||||
var modified = false;
|
||||
if (transactions.some(function (transaction) { return transaction.docChanged; })) {
|
||||
findChildren(nextState.doc, function (node) { return node.type.name === name; }).forEach(function (block) {
|
||||
var node = block.node, pos = block.pos;
|
||||
var docChanged = transactions.some(function (transaction) { return transaction.docChanged; });
|
||||
var selectionChanged = (nextState.selection.$from.parent.type.name === name ||
|
||||
prevState.selection.$from.parent.type.name === name) &&
|
||||
prevState.selection.$from.pos !== nextState.selection.$from.pos;
|
||||
findChildren(nextState.doc, function (node) { return node.type.name === name; }).forEach(function (block) {
|
||||
var node = block.node, pos = block.pos;
|
||||
var attributes = __assign({}, node.attrs);
|
||||
if (docChanged) {
|
||||
var lines = toCodeLines(node.textContent, pos);
|
||||
tr.setNodeMarkup(pos, undefined, __assign(__assign({}, node.attrs), { lines: lines }));
|
||||
attributes.lines = lines.slice();
|
||||
}
|
||||
if (selectionChanged) {
|
||||
var position = toCaretPosition(nextState.selection, docChanged ? toCodeLines(node.textContent, pos) : undefined);
|
||||
attributes.caretPosition = position;
|
||||
}
|
||||
if (docChanged || selectionChanged) {
|
||||
tr.setNodeMarkup(pos, node.type, attributes);
|
||||
modified = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return modified ? tr : null;
|
||||
},
|
||||
});
|
||||
@@ -211,8 +241,11 @@ export function HighlighterPlugin(_a) {
|
||||
function getActiveLineDecorations(doc, decorations, position) {
|
||||
var e_2, _a;
|
||||
var lineDecorations = decorations.find(undefined, undefined, function (_a) {
|
||||
var line = _a.line, active = _a.active;
|
||||
return (position && line === position.line) || active;
|
||||
var line = _a.line, active = _a.active, from = _a.from;
|
||||
var isSame = position
|
||||
? line === position.line && from === position.from
|
||||
: false;
|
||||
return isSame || active;
|
||||
});
|
||||
if (!lineDecorations.length)
|
||||
return decorations;
|
||||
@@ -241,3 +274,7 @@ function getActiveLineDecorations(doc, decorations, position) {
|
||||
}
|
||||
return decorations.add(doc, newDecorations);
|
||||
}
|
||||
function isAndroid() {
|
||||
var ua = navigator.userAgent.toLowerCase();
|
||||
return ua.indexOf("android") > -1; //&& ua.indexOf("mobile");
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export declare function isLanguageLoaded(name: string): boolean;
|
||||
export declare function loadLanguage(shortName: string): Promise<import("refractor/lib/core").Syntax | undefined>;
|
||||
|
||||
@@ -35,6 +35,9 @@ var __generator = (this && this.__generator) || function (thisArg, body) {
|
||||
}
|
||||
};
|
||||
var loadedLanguages = {};
|
||||
export function isLanguageLoaded(name) {
|
||||
return !!loadedLanguages[name];
|
||||
}
|
||||
export function loadLanguage(shortName) {
|
||||
return __awaiter(this, void 0, void 0, function () {
|
||||
var url, result;
|
||||
|
||||
50
packages/editor/dist/extensions/react/ReactNodeView.d.ts
vendored
Normal file
50
packages/editor/dist/extensions/react/ReactNodeView.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
241
packages/editor/dist/extensions/react/ReactNodeView.js
vendored
Normal file
241
packages/editor/dist/extensions/react/ReactNodeView.js
vendored
Normal 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 can’t 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);
|
||||
// we’ll check if every changed node is contentEditable
|
||||
// to make sure it’s 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));
|
||||
}
|
||||
38
packages/editor/dist/extensions/react/ReactNodeViewPortals.d.ts
vendored
Normal file
38
packages/editor/dist/extensions/react/ReactNodeViewPortals.d.ts
vendored
Normal 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 {};
|
||||
111
packages/editor/dist/extensions/react/ReactNodeViewPortals.js
vendored
Normal file
111
packages/editor/dist/extensions/react/ReactNodeViewPortals.js
vendored
Normal 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 };
|
||||
54
packages/editor/dist/extensions/react/SelectionBasedReactNodeView.d.ts
vendored
Normal file
54
packages/editor/dist/extensions/react/SelectionBasedReactNodeView.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
164
packages/editor/dist/extensions/react/SelectionBasedReactNodeView.js
vendored
Normal file
164
packages/editor/dist/extensions/react/SelectionBasedReactNodeView.js
vendored
Normal 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 };
|
||||
18
packages/editor/dist/extensions/react/eventdispatcher.d.ts
vendored
Normal file
18
packages/editor/dist/extensions/react/eventdispatcher.d.ts
vendored
Normal 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>;
|
||||
45
packages/editor/dist/extensions/react/eventdispatcher.js
vendored
Normal file
45
packages/editor/dist/extensions/react/eventdispatcher.js
vendored
Normal 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);
|
||||
};
|
||||
}
|
||||
13
packages/editor/dist/extensions/react/plugin.d.ts
vendored
Normal file
13
packages/editor/dist/extensions/react/plugin.d.ts
vendored
Normal 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;
|
||||
41
packages/editor/dist/extensions/react/plugin.js
vendored
Normal file
41
packages/editor/dist/extensions/react/plugin.js
vendored
Normal 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;
|
||||
31
packages/editor/dist/extensions/react/types.d.ts
vendored
Normal file
31
packages/editor/dist/extensions/react/types.d.ts
vendored
Normal 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;
|
||||
};
|
||||
1
packages/editor/dist/extensions/react/types.js
vendored
Normal file
1
packages/editor/dist/extensions/react/types.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
2
packages/editor/dist/index.d.ts
vendored
2
packages/editor/dist/index.d.ts
vendored
@@ -2,8 +2,10 @@ import { EditorOptions } from "./extensions/react";
|
||||
import Toolbar from "./toolbar";
|
||||
import { Theme } from "@notesnook/theme";
|
||||
import { AttachmentOptions } from "./extensions/attachment";
|
||||
import { PortalProviderAPI } from "./extensions/react/ReactNodeViewPortals";
|
||||
declare const useTiptap: (options?: Partial<EditorOptions & AttachmentOptions & {
|
||||
theme: Theme;
|
||||
portalProviderAPI: PortalProviderAPI;
|
||||
}>, deps?: React.DependencyList) => import("./extensions/react").Editor | null;
|
||||
export { useTiptap, Toolbar };
|
||||
export * from "./extensions/react";
|
||||
|
||||
10
packages/editor/dist/index.js
vendored
10
packages/editor/dist/index.js
vendored
@@ -53,6 +53,7 @@ import { SearchReplace } from "./extensions/search-replace";
|
||||
import { EmbedNode } from "./extensions/embed";
|
||||
import { CodeBlock } from "./extensions/code-block";
|
||||
import { ListItem } from "./extensions/list-item";
|
||||
import { EventDispatcher } from "./extensions/react/event-dispatcher";
|
||||
EditorView.prototype.updateState = function updateState(state) {
|
||||
if (!this.docView)
|
||||
return; // This prevents the matchesNode error on hot reloads
|
||||
@@ -60,7 +61,8 @@ EditorView.prototype.updateState = function updateState(state) {
|
||||
};
|
||||
var useTiptap = function (options, deps) {
|
||||
if (options === void 0) { options = {}; }
|
||||
var theme = options.theme, onCreate = options.onCreate, onDownloadAttachment = options.onDownloadAttachment, restOptions = __rest(options, ["theme", "onCreate", "onDownloadAttachment"]);
|
||||
var theme = options.theme, onCreate = options.onCreate, onDownloadAttachment = options.onDownloadAttachment, portalProviderAPI = options.portalProviderAPI, restOptions = __rest(options, ["theme", "onCreate", "onDownloadAttachment", "portalProviderAPI"]);
|
||||
var eventDispatcher = useMemo(function () { return new EventDispatcher(); }, []);
|
||||
var defaultOptions = useMemo(function () { return ({
|
||||
extensions: [
|
||||
SearchReplace,
|
||||
@@ -119,11 +121,15 @@ var useTiptap = function (options, deps) {
|
||||
if (theme) {
|
||||
editor.storage.theme = theme;
|
||||
}
|
||||
if (portalProviderAPI)
|
||||
editor.storage.portalProviderAPI = portalProviderAPI;
|
||||
if (eventDispatcher)
|
||||
editor.storage.eventDispatcher = eventDispatcher;
|
||||
if (onCreate)
|
||||
onCreate({ editor: editor });
|
||||
},
|
||||
injectCSS: false,
|
||||
}); }, [theme, onCreate, onDownloadAttachment]);
|
||||
}); }, [theme, onCreate, onDownloadAttachment, portalProviderAPI, eventDispatcher]);
|
||||
var editor = useEditor(__assign(__assign({}, defaultOptions), restOptions), deps);
|
||||
/**
|
||||
* Add editor to global for use in React Native.
|
||||
|
||||
138
packages/editor/package-lock.json
generated
138
packages/editor/package-lock.json
generated
@@ -35,6 +35,7 @@
|
||||
"@tiptap/extension-underline": "^2.0.0-beta.23",
|
||||
"@tiptap/starter-kit": "^2.0.0-beta.185",
|
||||
"detect-indent": "^7.0.0",
|
||||
"detect-indentation": "^5.20.0",
|
||||
"emotion-theming": "^10.0.19",
|
||||
"esm-loader": "^0.1.0",
|
||||
"highlight.js": "^11.5.1",
|
||||
@@ -52,7 +53,10 @@
|
||||
"react-toggle": "^4.1.2",
|
||||
"reactjs-popup": "^2.0.5",
|
||||
"rebass": "^4.0.7",
|
||||
"redent": "^4.0.0",
|
||||
"refractor": "^4.7.0",
|
||||
"shortid": "^2.2.16",
|
||||
"strip-indent": "^4.0.0",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"zustand": "^3.7.2"
|
||||
@@ -68,6 +72,7 @@
|
||||
"@types/react-toggle": "^4.0.3",
|
||||
"@types/rebass": "^4.0.10",
|
||||
"@types/rebass__forms": "^4.0.6",
|
||||
"@types/shortid": "^0.0.29",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"esm": "^3.2.25",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
@@ -4795,6 +4800,12 @@
|
||||
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/shortid": {
|
||||
"version": "0.0.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/shortid/-/shortid-0.0.29.tgz",
|
||||
"integrity": "sha512-9BCYD9btg2CY4kPcpMQ+vCR8U6V8f/KvixYD5ZbxoWlkhddNF5IeZMVL3p+QFUkg+Hb+kPAG9Jgk4bnnF1v/Fw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/source-list-map": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
|
||||
@@ -8641,6 +8652,17 @@
|
||||
"node": ">=12.20"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-indentation": {
|
||||
"version": "5.20.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-indentation/-/detect-indentation-5.20.0.tgz",
|
||||
"integrity": "sha512-nm/f3Pc8ESNfq5WV9x1wSZlzXrBopcjsRLmQahHqBqV8M1XgU3oWhj4xSJuMT5m0I7La7Z4G6983g7bigP6rmg==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://bevry.me/fund"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-newline": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
||||
@@ -15709,6 +15731,14 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mini-css-extract-plugin": {
|
||||
"version": "0.11.3",
|
||||
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.11.3.tgz",
|
||||
@@ -19725,6 +19755,32 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz",
|
||||
"integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==",
|
||||
"dependencies": {
|
||||
"indent-string": "^5.0.0",
|
||||
"strip-indent": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/redent/node_modules/indent-string": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
|
||||
"integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/refractor": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/refractor/-/refractor-4.7.0.tgz",
|
||||
@@ -21071,6 +21127,19 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/shortid": {
|
||||
"version": "2.2.16",
|
||||
"resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.16.tgz",
|
||||
"integrity": "sha512-Ugt+GIZqvGXCIItnsL+lvFJOiN7RYqlGy7QE41O3YC1xbNSeDGIRO7xg2JJXIAj1cAGnOeC1r7/T9pgrtQbv4g==",
|
||||
"dependencies": {
|
||||
"nanoid": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shortid/node_modules/nanoid": {
|
||||
"version": "2.1.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz",
|
||||
"integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA=="
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
|
||||
@@ -21961,6 +22030,20 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.0.0.tgz",
|
||||
"integrity": "sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==",
|
||||
"dependencies": {
|
||||
"min-indent": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
@@ -29091,6 +29174,12 @@
|
||||
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/shortid": {
|
||||
"version": "0.0.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/shortid/-/shortid-0.0.29.tgz",
|
||||
"integrity": "sha512-9BCYD9btg2CY4kPcpMQ+vCR8U6V8f/KvixYD5ZbxoWlkhddNF5IeZMVL3p+QFUkg+Hb+kPAG9Jgk4bnnF1v/Fw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/source-list-map": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
|
||||
@@ -32174,6 +32263,11 @@
|
||||
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.0.tgz",
|
||||
"integrity": "sha512-/6kJlmVv6RDFPqaHC/ZDcU8bblYcoph2dUQ3kB47QqhkUEqXe3VZPELK9BaEMrC73qu+wn0AQ7iSteceN+yuMw=="
|
||||
},
|
||||
"detect-indentation": {
|
||||
"version": "5.20.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-indentation/-/detect-indentation-5.20.0.tgz",
|
||||
"integrity": "sha512-nm/f3Pc8ESNfq5WV9x1wSZlzXrBopcjsRLmQahHqBqV8M1XgU3oWhj4xSJuMT5m0I7La7Z4G6983g7bigP6rmg=="
|
||||
},
|
||||
"detect-newline": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
||||
@@ -37598,6 +37692,11 @@
|
||||
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
|
||||
"dev": true
|
||||
},
|
||||
"min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="
|
||||
},
|
||||
"mini-css-extract-plugin": {
|
||||
"version": "0.11.3",
|
||||
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.11.3.tgz",
|
||||
@@ -40891,6 +40990,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"redent": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz",
|
||||
"integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==",
|
||||
"requires": {
|
||||
"indent-string": "^5.0.0",
|
||||
"strip-indent": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"indent-string": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
|
||||
"integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"refractor": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/refractor/-/refractor-4.7.0.tgz",
|
||||
@@ -41967,6 +42082,21 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"shortid": {
|
||||
"version": "2.2.16",
|
||||
"resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.16.tgz",
|
||||
"integrity": "sha512-Ugt+GIZqvGXCIItnsL+lvFJOiN7RYqlGy7QE41O3YC1xbNSeDGIRO7xg2JJXIAj1cAGnOeC1r7/T9pgrtQbv4g==",
|
||||
"requires": {
|
||||
"nanoid": "^2.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"nanoid": {
|
||||
"version": "2.1.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz",
|
||||
"integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"side-channel": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
|
||||
@@ -42707,6 +42837,14 @@
|
||||
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
|
||||
"dev": true
|
||||
},
|
||||
"strip-indent": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.0.0.tgz",
|
||||
"integrity": "sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==",
|
||||
"requires": {
|
||||
"min-indent": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"@tiptap/extension-underline": "^2.0.0-beta.23",
|
||||
"@tiptap/starter-kit": "^2.0.0-beta.185",
|
||||
"detect-indent": "^7.0.0",
|
||||
"detect-indentation": "^5.20.0",
|
||||
"emotion-theming": "^10.0.19",
|
||||
"esm-loader": "^0.1.0",
|
||||
"highlight.js": "^11.5.1",
|
||||
@@ -48,7 +49,10 @@
|
||||
"react-toggle": "^4.1.2",
|
||||
"reactjs-popup": "^2.0.5",
|
||||
"rebass": "^4.0.7",
|
||||
"redent": "^4.0.0",
|
||||
"refractor": "^4.7.0",
|
||||
"shortid": "^2.2.16",
|
||||
"strip-indent": "^4.0.0",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"zustand": "^3.7.2"
|
||||
@@ -64,6 +68,7 @@
|
||||
"@types/react-toggle": "^4.0.3",
|
||||
"@types/rebass": "^4.0.10",
|
||||
"@types/rebass__forms": "^4.0.6",
|
||||
"@types/shortid": "^0.0.29",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"esm": "^3.2.25",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Editor, NodeViewRendererProps } from "@tiptap/core";
|
||||
import { Node, textblockTypeInputRule, mergeAttributes } from "@tiptap/core";
|
||||
import {
|
||||
Plugin,
|
||||
@@ -11,18 +11,22 @@ import { ResolvedPos, Node as ProsemirrorNode } from "prosemirror-model";
|
||||
import { findParentNodeClosestToPos, ReactNodeViewRenderer } from "../react";
|
||||
import { CodeblockComponent } from "./component";
|
||||
import { HighlighterPlugin } from "./highlighter";
|
||||
import ReactNodeView from "../react/ReactNodeView";
|
||||
import detectIndent from "detect-indent";
|
||||
import redent from "redent";
|
||||
import stripIndent from "strip-indent";
|
||||
|
||||
export type IndentationOptions = {
|
||||
type: "space" | "tab";
|
||||
length: number;
|
||||
};
|
||||
interface Indent {
|
||||
type: "tab" | "space";
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export type CodeBlockAttributes = {
|
||||
indentType: IndentationOptions["type"];
|
||||
indentType: Indent["type"];
|
||||
indentLength: number;
|
||||
language: string;
|
||||
lines: CodeLine[];
|
||||
caretPosition?: CaretPosition;
|
||||
};
|
||||
|
||||
export interface CodeBlockOptions {
|
||||
@@ -67,7 +71,7 @@ declare module "@tiptap/core" {
|
||||
/**
|
||||
* Change code block indentation options
|
||||
*/
|
||||
changeCodeBlockIndentation: (options: IndentationOptions) => ReturnType;
|
||||
changeCodeBlockIndentation: (options: Indent) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -101,6 +105,10 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
caretPosition: {
|
||||
default: undefined,
|
||||
rendered: false,
|
||||
},
|
||||
lines: {
|
||||
default: [],
|
||||
rendered: false,
|
||||
@@ -109,8 +117,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
default: "space",
|
||||
parseHTML: (element) => {
|
||||
const indentType = element.dataset.indentType;
|
||||
if (indentType) return indentType;
|
||||
return detectIndent(element.innerText).type;
|
||||
return indentType;
|
||||
},
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.indentType) {
|
||||
@@ -125,8 +132,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
default: 2,
|
||||
parseHTML: (element) => {
|
||||
const indentLength = element.dataset.indentLength;
|
||||
if (indentLength) return indentLength;
|
||||
return detectIndent(element.innerText).amount;
|
||||
return indentLength;
|
||||
},
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.indentLength) {
|
||||
@@ -188,7 +194,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return [
|
||||
"pre",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
0,
|
||||
["code", {}, 0],
|
||||
];
|
||||
},
|
||||
|
||||
@@ -223,7 +229,10 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
if (!whitespaceLength) continue;
|
||||
|
||||
const indentLength = whitespaceLength;
|
||||
const indentToken = indent(options.type, indentLength);
|
||||
const indentToken = indent({
|
||||
type: options.type,
|
||||
amount: indentLength,
|
||||
});
|
||||
|
||||
tr.insertText(
|
||||
indentToken,
|
||||
@@ -234,7 +243,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
|
||||
commands.updateAttributes(this.type, {
|
||||
indentType: options.type,
|
||||
indentLength: options.length,
|
||||
indentLength: options.amount,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
@@ -263,13 +272,12 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
Backspace: () => {
|
||||
const { empty, $anchor } = this.editor.state.selection;
|
||||
const isAtStart = $anchor.pos === 1;
|
||||
|
||||
if (!empty || $anchor.parent.type.name !== this.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAtStart || !$anchor.parent.textContent.length) {
|
||||
return this.editor.commands.clearNodes();
|
||||
return this.editor.commands.deleteNode(this.type);
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -285,7 +293,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return false;
|
||||
}
|
||||
|
||||
const indentation = parseIndentation($from.parent);
|
||||
const indentation = parseIndentation($from.parent)!;
|
||||
|
||||
return (
|
||||
(this.options.exitOnTripleEnter &&
|
||||
@@ -359,8 +367,8 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return false;
|
||||
}
|
||||
|
||||
const indentation = parseIndentation($from.parent);
|
||||
const indentToken = indent(indentation.type, indentation.length);
|
||||
const indentation = parseIndentation($from.parent)!;
|
||||
const indentToken = indent(indentation);
|
||||
|
||||
const { lines } = $from.parent.attrs as CodeBlockAttributes;
|
||||
const selectedLines = getSelectedLines(lines, selection);
|
||||
@@ -374,7 +382,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
|
||||
tr.delete(
|
||||
tr.mapping.map(line.from),
|
||||
tr.mapping.map(line.from + indentation.length)
|
||||
tr.mapping.map(line.from + indentation.amount)
|
||||
);
|
||||
}
|
||||
})
|
||||
@@ -396,8 +404,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
.chain()
|
||||
.command(({ tr }) =>
|
||||
withSelection(tr, (tr) => {
|
||||
const indentation = parseIndentation($from.parent);
|
||||
const indentToken = indent(indentation.type, indentation.length);
|
||||
const indentToken = indent(parseIndentation($from.parent)!);
|
||||
|
||||
if (selectedLines.length === 1)
|
||||
return tr.insertText(indentToken, $from.pos);
|
||||
@@ -443,11 +450,6 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return false;
|
||||
}
|
||||
|
||||
// don’t create a new code block within code blocks
|
||||
if (this.editor.isActive(this.type.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
const vscode = event.clipboardData.getData("vscode-editor-data");
|
||||
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
|
||||
@@ -457,22 +459,35 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return false;
|
||||
}
|
||||
|
||||
const indent = fixIndentation(
|
||||
text,
|
||||
parseIndentation(view.state.selection.$from.parent)
|
||||
);
|
||||
|
||||
const { tr } = view.state;
|
||||
|
||||
// create an empty code block
|
||||
tr.replaceSelectionWith(this.type.create({ language }));
|
||||
// create an empty code block if not already within one
|
||||
if (!this.editor.isActive(this.type.name)) {
|
||||
tr.replaceSelectionWith(
|
||||
this.type.create({
|
||||
language,
|
||||
indentType: indent.type,
|
||||
indentLength: indent.amount,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// put cursor inside the newly created code block
|
||||
tr.setSelection(
|
||||
TextSelection.near(
|
||||
tr.doc.resolve(Math.max(0, tr.selection.from - 2))
|
||||
)
|
||||
);
|
||||
// // put cursor inside the newly created code block
|
||||
// tr.setSelection(
|
||||
// TextSelection.near(
|
||||
// tr.doc.resolve(Math.max(0, tr.selection.from - 2))
|
||||
// )
|
||||
// );
|
||||
|
||||
// add text to code block
|
||||
// strip carriage return chars from text pasted as code
|
||||
// see: https://github.com/ProseMirror/prosemirror-view/commit/a50a6bcceb4ce52ac8fcc6162488d8875613aacd
|
||||
tr.insertText(text.replace(/\r\n?/g, "\n"));
|
||||
tr.insertText(indent.code.replace(/\r\n?/g, "\n"));
|
||||
|
||||
// store meta information
|
||||
// this is useful for other plugins that depends on the paste event
|
||||
@@ -490,7 +505,23 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CodeblockComponent);
|
||||
return ReactNodeView.fromComponent(CodeblockComponent, {
|
||||
contentDOMFactory: () => {
|
||||
const content = document.createElement("div");
|
||||
content.classList.add("node-content-wrapper");
|
||||
content.style.whiteSpace = "inherit";
|
||||
// caret is not visible if content element width is 0px
|
||||
content.style.minWidth = `20px`;
|
||||
return { dom: content };
|
||||
},
|
||||
shouldUpdate: ({ attrs: prev }, { attrs: next }) => {
|
||||
return (
|
||||
compareCaretPosition(prev.caretPosition, next.caretPosition) ||
|
||||
prev.language !== next.language ||
|
||||
prev.indentType !== next.indentType
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -499,13 +530,15 @@ export type CaretPosition = {
|
||||
line: number;
|
||||
selected?: number;
|
||||
total: number;
|
||||
from: number;
|
||||
};
|
||||
export function toCaretPosition(
|
||||
lines: CodeLine[],
|
||||
selection: Selection
|
||||
selection: Selection,
|
||||
lines?: CodeLine[]
|
||||
): CaretPosition | undefined {
|
||||
const { $from, $to, $head } = selection;
|
||||
if ($from.parent.type.name !== CodeBlock.name) return;
|
||||
lines = lines || getLines($from.parent);
|
||||
|
||||
for (const line of lines) {
|
||||
if ($head.pos >= line.from && $head.pos <= line.to) {
|
||||
@@ -515,6 +548,7 @@ export function toCaretPosition(
|
||||
column: lineLength - (line.to - $head.pos),
|
||||
selected: $to.pos - $from.pos,
|
||||
total: lines.length,
|
||||
from: line.from,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -545,22 +579,31 @@ function exitOnTripleEnter(editor: Editor, $from: ResolvedPos) {
|
||||
.run();
|
||||
}
|
||||
|
||||
function indentOnEnter(
|
||||
editor: Editor,
|
||||
$from: ResolvedPos,
|
||||
options: IndentationOptions
|
||||
) {
|
||||
function indentOnEnter(editor: Editor, $from: ResolvedPos, options: Indent) {
|
||||
const { indentation, newline } = getNewline($from, options) || {};
|
||||
if (!newline) return false;
|
||||
|
||||
return editor
|
||||
.chain()
|
||||
.insertContent(`${newline}${indentation}`, {
|
||||
parseOptions: { preserveWhitespace: "full" },
|
||||
})
|
||||
.focus()
|
||||
.run();
|
||||
}
|
||||
|
||||
function getNewline($from: ResolvedPos, options: Indent) {
|
||||
const { lines } = $from.parent.attrs as CodeBlockAttributes;
|
||||
const currentLine = getLineAt(lines, $from.pos);
|
||||
if (!currentLine) return false;
|
||||
|
||||
const text = editor.state.doc.textBetween(currentLine.from, currentLine.to);
|
||||
const text = currentLine.text();
|
||||
const indentLength = text.length - text.trimStart().length;
|
||||
|
||||
const newline = `${NEWLINE}${indent(options.type, indentLength)}`;
|
||||
return editor.commands.insertContent(newline, {
|
||||
parseOptions: { preserveWhitespace: "full" },
|
||||
});
|
||||
return {
|
||||
newline: NEWLINE,
|
||||
indentation: indent({ amount: indentLength, type: options.type }),
|
||||
};
|
||||
}
|
||||
|
||||
type CodeLine = {
|
||||
@@ -613,11 +656,13 @@ function getSelectedLines(lines: CodeLine[], selection: Selection) {
|
||||
);
|
||||
}
|
||||
|
||||
function parseIndentation(node: ProsemirrorNode): IndentationOptions {
|
||||
function parseIndentation(node: ProsemirrorNode): Indent | undefined {
|
||||
if (node.type.name !== CodeBlock.name) return undefined;
|
||||
|
||||
const { indentType, indentLength } = node.attrs;
|
||||
return {
|
||||
type: indentType,
|
||||
length: parseInt(indentLength),
|
||||
amount: parseInt(indentLength),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -629,9 +674,20 @@ function inRange(x: number, a: number, b: number) {
|
||||
return x >= a && x <= b;
|
||||
}
|
||||
|
||||
function indent(type: IndentationOptions["type"], length: number) {
|
||||
const char = type === "space" ? " " : "\t";
|
||||
return char.repeat(length);
|
||||
function indent(options: Indent) {
|
||||
const char = options.type === "space" ? " " : "\t";
|
||||
return char.repeat(options.amount);
|
||||
}
|
||||
|
||||
function compareCaretPosition(
|
||||
prev: CaretPosition | undefined,
|
||||
next: CaretPosition | undefined
|
||||
): boolean {
|
||||
return (
|
||||
next === undefined ||
|
||||
prev?.column !== next?.column ||
|
||||
prev?.line !== next?.line
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -653,3 +709,15 @@ function withSelection(
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function fixIndentation(
|
||||
code: string,
|
||||
indent?: Indent
|
||||
): { code: string } & Indent {
|
||||
const { amount, type = "space" } = indent || detectIndent(code);
|
||||
const fixed = redent(code, amount, {
|
||||
includeEmptyLines: false,
|
||||
indent: type === "space" ? " " : "\t",
|
||||
});
|
||||
return { code: stripIndent(fixed), amount, type };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "../react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { loadLanguage } from "./loader";
|
||||
import { isLanguageLoaded, loadLanguage } from "./loader";
|
||||
import { refractor } from "refractor/lib/core";
|
||||
import "prism-themes/themes/prism-dracula.min.css";
|
||||
import { Theme } from "@notesnook/theme";
|
||||
@@ -11,22 +10,18 @@ import { PopupPresenter } from "../../components/menu/menu";
|
||||
import { Input } from "@rebass/forms";
|
||||
import { Icon } from "../../toolbar/components/icon";
|
||||
import { Icons } from "../../toolbar/icons";
|
||||
import {
|
||||
CodeBlockAttributes,
|
||||
toCaretPosition,
|
||||
CaretPosition,
|
||||
getLines,
|
||||
} from "./code-block";
|
||||
import { Transaction } from "prosemirror-state";
|
||||
import { CodeBlockAttributes } from "./code-block";
|
||||
import { ReactComponentProps } from "../react/types";
|
||||
|
||||
export function CodeblockComponent(props: NodeViewProps) {
|
||||
const { editor, updateAttributes, node } = props;
|
||||
const { language, indentLength, indentType } =
|
||||
node.attrs as CodeBlockAttributes;
|
||||
const theme = editor.storage.theme as Theme;
|
||||
export function CodeblockComponent(
|
||||
props: ReactComponentProps<CodeBlockAttributes>
|
||||
) {
|
||||
const { editor, updateAttributes, node, forwardRef } = props;
|
||||
const { language, indentLength, indentType, caretPosition } = node?.attrs;
|
||||
const theme = editor?.storage.theme as Theme;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [caretPosition, setCaretPosition] = useState<CaretPosition>();
|
||||
// const [caretPosition, setCaretPosition] = useState<CaretPosition>();
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const languageDefinition = Languages.find(
|
||||
@@ -35,145 +30,131 @@ export function CodeblockComponent(props: NodeViewProps) {
|
||||
|
||||
useEffect(() => {
|
||||
(async function () {
|
||||
if (!language || !languageDefinition) {
|
||||
updateAttributes({ language: null });
|
||||
if (!language || !languageDefinition || isLanguageLoaded(language))
|
||||
return;
|
||||
}
|
||||
|
||||
const syntax = await loadLanguage(languageDefinition.filename);
|
||||
if (!syntax) return;
|
||||
|
||||
refractor.register(syntax);
|
||||
|
||||
updateAttributes({
|
||||
language: languageDefinition.filename,
|
||||
});
|
||||
})();
|
||||
}, [language, updateAttributes]);
|
||||
|
||||
useEffect(() => {
|
||||
function onSelectionUpdate({ transaction }: { transaction: Transaction }) {
|
||||
const position = toCaretPosition(getLines(node), transaction.selection);
|
||||
setCaretPosition(position);
|
||||
}
|
||||
|
||||
editor.on("selectionUpdate", onSelectionUpdate);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", onSelectionUpdate);
|
||||
};
|
||||
}, [node]);
|
||||
}, [language]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Flex
|
||||
<ThemeProvider theme={theme}>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
borderRadius: "default",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
ref={forwardRef}
|
||||
as="pre"
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
borderRadius: "default",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
as="pre"
|
||||
sx={{
|
||||
"div, span.token, span.line-number-widget": {
|
||||
"div, span.token, span.line-number-widget, span.line-number::before":
|
||||
{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "code",
|
||||
whiteSpace: "pre !important",
|
||||
tabSize: 1,
|
||||
},
|
||||
position: "relative",
|
||||
lineHeight: "20px",
|
||||
bg: "codeBg",
|
||||
color: "static",
|
||||
overflowX: "auto",
|
||||
display: "flex",
|
||||
px: 2,
|
||||
pt: 2,
|
||||
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();
|
||||
position: "relative",
|
||||
lineHeight: "20px",
|
||||
bg: "codeBg",
|
||||
color: "static",
|
||||
overflowX: "auto",
|
||||
display: "flex",
|
||||
px: 2,
|
||||
pt: 2,
|
||||
pb: 1,
|
||||
}}
|
||||
mobile="sheet"
|
||||
desktop="menu"
|
||||
options={{
|
||||
type: "menu",
|
||||
position: {
|
||||
target: toolbarRef.current || undefined,
|
||||
align: "end",
|
||||
isTargetAbsolute: true,
|
||||
location: "top",
|
||||
yOffset: 5,
|
||||
},
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Flex
|
||||
ref={toolbarRef}
|
||||
contentEditable={false}
|
||||
sx={{
|
||||
bg: "codeBg",
|
||||
alignItems: "center",
|
||||
justifyContent: "end",
|
||||
borderTop: "1px solid var(--codeBorder)",
|
||||
}}
|
||||
>
|
||||
<LanguageSelector
|
||||
selectedLanguage={languageDefinition?.filename || "Plaintext"}
|
||||
onLanguageSelected={(language) => updateAttributes({ language })}
|
||||
/>
|
||||
</PopupPresenter>
|
||||
</ThemeProvider>
|
||||
</NodeViewWrapper>
|
||||
{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",
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ function parseNodes(
|
||||
|
||||
if (node.type === "element" && node.properties)
|
||||
classes.push(...(node.properties.className as string[]));
|
||||
// this is required so that even plain text is wrapped in a span
|
||||
// during highlighting. Without this, Prosemirror's selection acts
|
||||
// weird for the first highlighted node/span.
|
||||
else classes.push("token", "text");
|
||||
|
||||
if (node.type === "element") {
|
||||
@@ -57,20 +60,27 @@ function getLineDecoration(
|
||||
total: number,
|
||||
isActive: boolean
|
||||
) {
|
||||
const maxLength = String(total).length;
|
||||
const attributes = {
|
||||
class: `line-number ${isActive ? "active" : ""}`,
|
||||
"data-line": String(line).padEnd(String(total).length, " "),
|
||||
"data-line": String(line).padEnd(maxLength, " "),
|
||||
};
|
||||
const spec: any = {
|
||||
line: line,
|
||||
active: isActive,
|
||||
total,
|
||||
from,
|
||||
};
|
||||
|
||||
// Prosemirror has a selection issue with the widget decoration
|
||||
// on the first line. To work around that we use inline decoration
|
||||
// for the first line.
|
||||
if (line === 1) {
|
||||
if (
|
||||
line === 1 ||
|
||||
// Android Composition API (aka the virtual keyboard) doesn't behave well
|
||||
// with Decoration widgets so we have to resort to inline line numbers.
|
||||
isAndroid()
|
||||
) {
|
||||
return Decoration.inline(from, from + 1, attributes, spec);
|
||||
}
|
||||
|
||||
@@ -85,7 +95,11 @@ function getLineDecoration(
|
||||
},
|
||||
{
|
||||
...spec,
|
||||
key: `${line}-${isActive ? "active" : "inactive"}`,
|
||||
// should rerender when any of these change:
|
||||
// 1. line number
|
||||
// 2. line active state
|
||||
// 3. the max length of all lines
|
||||
key: `${line}-${isActive ? "active" : ""}-${maxLength}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -94,9 +108,9 @@ function getDecorations({
|
||||
doc,
|
||||
name,
|
||||
defaultLanguage,
|
||||
currentLine,
|
||||
caretPosition,
|
||||
}: {
|
||||
currentLine?: number;
|
||||
caretPosition?: CaretPosition;
|
||||
doc: ProsemirrorNode;
|
||||
name: string;
|
||||
defaultLanguage: string | null | undefined;
|
||||
@@ -106,10 +120,11 @@ function getDecorations({
|
||||
findChildren(doc, (node) => node.type.name === name).forEach((block) => {
|
||||
const code = block.node.textContent;
|
||||
|
||||
const { lines } = block.node.attrs as CodeBlockAttributes;
|
||||
const lines = toCodeLines(code, block.pos);
|
||||
for (const line of lines || []) {
|
||||
const lineNumber = line.index + 1;
|
||||
const isActive = lineNumber === currentLine;
|
||||
const isActive =
|
||||
lineNumber === caretPosition?.line && line.from === caretPosition?.from;
|
||||
const decoration = getLineDecoration(
|
||||
line.from,
|
||||
lineNumber,
|
||||
@@ -124,19 +139,15 @@ function getDecorations({
|
||||
? getHighlightNodes(refractor.highlight(code, language))
|
||||
: null;
|
||||
if (!nodes) return;
|
||||
|
||||
let from = block.pos + 1;
|
||||
parseNodes(nodes).forEach((node) => {
|
||||
const to = from + node.text.length;
|
||||
|
||||
if (node.classes.length) {
|
||||
const decoration = Decoration.inline(from, to, {
|
||||
class: node.classes.join(" "),
|
||||
});
|
||||
|
||||
decorations.push(decoration);
|
||||
}
|
||||
|
||||
from = to;
|
||||
});
|
||||
});
|
||||
@@ -177,11 +188,12 @@ export function HighlighterPlugin({
|
||||
(node) => node.type.name === name
|
||||
);
|
||||
|
||||
const position = toCaretPosition(
|
||||
getLines(newState.selection.$head.parent),
|
||||
newState.selection
|
||||
);
|
||||
|
||||
const position = toCaretPosition(newState.selection);
|
||||
// const isDocChanged =
|
||||
// transaction.docChanged &&
|
||||
// // TODO
|
||||
// !transaction.steps.every((step) => step instanceof ReplaceAroundStep);
|
||||
// console.log("Selection", transaction.docChanged, isDocChanged);
|
||||
if (
|
||||
transaction.docChanged &&
|
||||
// Apply decorations if:
|
||||
@@ -209,7 +221,7 @@ export function HighlighterPlugin({
|
||||
doc: transaction.doc,
|
||||
name,
|
||||
defaultLanguage,
|
||||
currentLine: position?.line,
|
||||
caretPosition: position,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -229,23 +241,42 @@ export function HighlighterPlugin({
|
||||
},
|
||||
},
|
||||
|
||||
appendTransaction: (transactions, _prevState, nextState) => {
|
||||
appendTransaction: (transactions, prevState, nextState) => {
|
||||
const tr = nextState.tr;
|
||||
let modified = false;
|
||||
|
||||
if (transactions.some((transaction) => transaction.docChanged)) {
|
||||
findChildren(nextState.doc, (node) => node.type.name === name).forEach(
|
||||
(block) => {
|
||||
const { node, pos } = block;
|
||||
const docChanged = transactions.some(
|
||||
(transaction) => transaction.docChanged
|
||||
);
|
||||
const selectionChanged =
|
||||
(nextState.selection.$from.parent.type.name === name ||
|
||||
prevState.selection.$from.parent.type.name === name) &&
|
||||
prevState.selection.$from.pos !== nextState.selection.$from.pos;
|
||||
|
||||
findChildren(nextState.doc, (node) => node.type.name === name).forEach(
|
||||
(block) => {
|
||||
const { node, pos } = block;
|
||||
const attributes = { ...node.attrs };
|
||||
|
||||
if (docChanged) {
|
||||
const lines = toCodeLines(node.textContent, pos);
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
lines,
|
||||
});
|
||||
attributes.lines = lines.slice();
|
||||
}
|
||||
|
||||
if (selectionChanged) {
|
||||
const position = toCaretPosition(
|
||||
nextState.selection,
|
||||
docChanged ? toCodeLines(node.textContent, pos) : undefined
|
||||
);
|
||||
attributes.caretPosition = position;
|
||||
}
|
||||
|
||||
if (docChanged || selectionChanged) {
|
||||
tr.setNodeMarkup(pos, node.type, attributes);
|
||||
modified = true;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return modified ? tr : null;
|
||||
},
|
||||
@@ -264,8 +295,11 @@ function getActiveLineDecorations(
|
||||
const lineDecorations = decorations.find(
|
||||
undefined,
|
||||
undefined,
|
||||
({ line, active }) => {
|
||||
return (position && line === position.line) || active;
|
||||
({ line, active, from }) => {
|
||||
const isSame = position
|
||||
? line === position.line && from === position.from
|
||||
: false;
|
||||
return isSame || active;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -297,3 +331,8 @@ function getActiveLineDecorations(
|
||||
}
|
||||
return decorations.add(doc, newDecorations);
|
||||
}
|
||||
|
||||
function isAndroid() {
|
||||
var ua = navigator.userAgent.toLowerCase();
|
||||
return ua.indexOf("android") > -1; //&& ua.indexOf("mobile");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Syntax } from "refractor";
|
||||
|
||||
const loadedLanguages: Record<string, Syntax | undefined> = {};
|
||||
export function isLanguageLoaded(name: string) {
|
||||
return !!loadedLanguages[name];
|
||||
}
|
||||
export async function loadLanguage(shortName: string) {
|
||||
if (loadedLanguages[shortName]) return loadedLanguages[shortName];
|
||||
|
||||
|
||||
299
packages/editor/src/extensions/react/ReactNodeView.tsx
Normal file
299
packages/editor/src/extensions/react/ReactNodeView.tsx
Normal 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 can’t 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[];
|
||||
|
||||
// we’ll check if every changed node is contentEditable
|
||||
// to make sure it’s 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)
|
||||
);
|
||||
}
|
||||
117
packages/editor/src/extensions/react/ReactNodeViewPortals.tsx
Normal file
117
packages/editor/src/extensions/react/ReactNodeViewPortals.tsx
Normal 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)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
64
packages/editor/src/extensions/react/event-dispatcher.ts
Normal file
64
packages/editor/src/extensions/react/event-dispatcher.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
52
packages/editor/src/extensions/react/plugin.ts
Normal file
52
packages/editor/src/extensions/react/plugin.ts
Normal 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;
|
||||
34
packages/editor/src/extensions/react/types.ts
Normal file
34
packages/editor/src/extensions/react/types.ts
Normal 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;
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import { EditorOptions, useEditor } from "./extensions/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import Toolbar from "./toolbar";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
@@ -32,6 +32,8 @@ import { SearchReplace } from "./extensions/search-replace";
|
||||
import { EmbedNode } from "./extensions/embed";
|
||||
import { CodeBlock } from "./extensions/code-block";
|
||||
import { ListItem } from "./extensions/list-item";
|
||||
import { PortalProviderAPI } from "./extensions/react/ReactNodeViewPortals";
|
||||
import { EventDispatcher } from "./extensions/react/event-dispatcher";
|
||||
|
||||
EditorView.prototype.updateState = function updateState(state) {
|
||||
if (!(this as any).docView) return; // This prevents the matchesNode error on hot reloads
|
||||
@@ -39,10 +41,20 @@ EditorView.prototype.updateState = function updateState(state) {
|
||||
};
|
||||
|
||||
const useTiptap = (
|
||||
options: Partial<EditorOptions & AttachmentOptions & { theme: Theme }> = {},
|
||||
options: Partial<
|
||||
EditorOptions &
|
||||
AttachmentOptions & { theme: Theme; portalProviderAPI: PortalProviderAPI }
|
||||
> = {},
|
||||
deps?: React.DependencyList
|
||||
) => {
|
||||
const { theme, onCreate, onDownloadAttachment, ...restOptions } = options;
|
||||
const {
|
||||
theme,
|
||||
onCreate,
|
||||
onDownloadAttachment,
|
||||
portalProviderAPI,
|
||||
...restOptions
|
||||
} = options;
|
||||
const eventDispatcher = useMemo(() => new EventDispatcher(), []);
|
||||
|
||||
const defaultOptions = useMemo<Partial<EditorOptions>>(
|
||||
() => ({
|
||||
@@ -103,11 +115,15 @@ const useTiptap = (
|
||||
if (theme) {
|
||||
editor.storage.theme = theme;
|
||||
}
|
||||
if (portalProviderAPI)
|
||||
editor.storage.portalProviderAPI = portalProviderAPI;
|
||||
if (eventDispatcher) editor.storage.eventDispatcher = eventDispatcher;
|
||||
|
||||
if (onCreate) onCreate({ editor });
|
||||
},
|
||||
injectCSS: false,
|
||||
}),
|
||||
[theme, onCreate, onDownloadAttachment]
|
||||
[theme, onCreate, onDownloadAttachment, portalProviderAPI, eventDispatcher]
|
||||
);
|
||||
|
||||
const editor = useEditor({ ...defaultOptions, ...restOptions }, deps);
|
||||
|
||||
15
packages/editor/test.js
Normal file
15
packages/editor/test.js
Normal 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(
|
||||
);
|
||||
Reference in New Issue
Block a user