editor: add support for adding/removing internal links

This commit is contained in:
Abdullah Atta
2024-01-22 17:27:25 +05:00
parent 2a547c879b
commit 54f1945a58
51 changed files with 1075 additions and 584 deletions

View File

@@ -41,6 +41,7 @@
"detect-indent": "^7.0.0",
"entities": "^4.5.0",
"katex": "0.16.4",
"linkifyjs": "^4.1.3",
"nanoid": "^4.0.1",
"prism-themes": "^1.9.0",
"prosemirror-codemark": "^0.4.2",
@@ -973,6 +974,22 @@
"@styled-system/css": "^5.1.5"
}
},
"node_modules/@theme-ui/color-modes": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@theme-ui/color-modes/-/color-modes-0.16.1.tgz",
"integrity": "sha512-G2YoNEMwZroRS0DcftUG+E/8WM5/Osf8TRrQLLK+L43HJ4BmaWuBmVeyoNOaPBDlAuqMBx2203VRgoPmUaMqOg==",
"dev": true,
"peer": true,
"dependencies": {
"@theme-ui/core": "^0.16.1",
"@theme-ui/css": "^0.16.1",
"deepmerge": "^4.2.2"
},
"peerDependencies": {
"@emotion/react": "^11.11.1",
"react": ">=18"
}
},
"node_modules/@theme-ui/components": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@theme-ui/components/-/components-0.16.1.tgz",
@@ -1018,6 +1035,22 @@
"@emotion/react": "^11.11.1"
}
},
"node_modules/@theme-ui/theme-provider": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@theme-ui/theme-provider/-/theme-provider-0.16.1.tgz",
"integrity": "sha512-+/3BJYLIOC2DwTS76cqNhigRQJJ+qOT845DYF7t3TaG2fXDfgh16/DGZSnVjGOGc9dYE3C/ZFAYcVDVwO94Guw==",
"dev": true,
"peer": true,
"dependencies": {
"@theme-ui/color-modes": "^0.16.1",
"@theme-ui/core": "^0.16.1",
"@theme-ui/css": "^0.16.1"
},
"peerDependencies": {
"@emotion/react": "^11.11.1",
"react": ">=18"
}
},
"node_modules/@tiptap/core": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.1.12.tgz",
@@ -2508,8 +2541,7 @@
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
@@ -2573,7 +2605,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@@ -3149,7 +3180,6 @@
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"dev": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -3170,7 +3200,6 @@
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"dev": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
@@ -3302,7 +3331,6 @@
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
"dev": true,
"dependencies": {
"loose-envify": "^1.1.0"
}

View File

@@ -36,6 +36,7 @@
"detect-indent": "^7.0.0",
"entities": "^4.5.0",
"katex": "0.16.4",
"linkifyjs": "^4.1.3",
"nanoid": "^4.0.1",
"prism-themes": "^1.9.0",
"prosemirror-codemark": "^0.4.2",
@@ -48,11 +49,11 @@
"unfurl.js": "^6.3.2"
},
"devDependencies": {
"react-modal": "3.16.1",
"tinycolor2": "^1.6.0",
"zustand": "4.4.7",
"@emotion/react": "11.11.1",
"@mdi/js": "^7.2.96",
"@mdi/react": "^1.6.1",
"@theme-ui/components": "^0.16.1",
"@theme-ui/core": "^0.16.1",
"@types/katex": "^0.14.0",
"@types/prismjs": "^1.26.0",
"@types/react": "^18.2.39",
@@ -64,26 +65,26 @@
"happy-dom": "^8.9.0",
"isomorphic-fetch": "^3.0.0",
"prosemirror-test-builder": "^1.1.0",
"@theme-ui/components": "^0.16.1",
"@theme-ui/core": "^0.16.1",
"@emotion/react": "11.11.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-modal": "3.16.1",
"tinycolor2": "^1.6.0",
"vitest": "^0.29.2",
"web-vitals": "^2.1.4"
"web-vitals": "^2.1.4",
"zustand": "4.4.7"
},
"peerDependencies": {
"framer-motion": ">=10",
"@emotion/react": ">=11",
"@mdi/js": ">=7.2.96",
"@mdi/react": ">=1.6.1",
"@theme-ui/components": ">=0.16.0",
"@theme-ui/core": ">=0.16.0",
"tinycolor2": ">=1.6",
"zustand": ">=4",
"react-modal": ">=3",
"framer-motion": ">=10",
"react": ">=18",
"react-dom": ">=18"
"react-dom": ">=18",
"react-modal": ">=3",
"tinycolor2": ">=1.6",
"zustand": ">=4"
},
"scripts": {
"test": "vitest run",

View File

@@ -28,3 +28,24 @@ index 38c9884..4a9e10c 100644
};
const liftListItem = typeOrName => ({ state, dispatch }) => {
diff --git a/node_modules/@tiptap/core/dist/packages/core/src/Editor.d.ts b/node_modules/@tiptap/core/dist/packages/core/src/Editor.d.ts
index cb0ede6..8a19ff2 100644
--- a/node_modules/@tiptap/core/dist/packages/core/src/Editor.d.ts
+++ b/node_modules/@tiptap/core/dist/packages/core/src/Editor.d.ts
@@ -6,6 +6,7 @@ import { ExtensionManager } from './ExtensionManager.js';
import * as extensions from './extensions/index.js';
import { CanCommands, ChainedCommands, EditorEvents, EditorOptions, JSONContent, SingleCommands, TextSerializer } from './types.js';
export { extensions };
+export interface EditorStorage extends Record<string, any> { }
export interface HTMLElement {
editor?: Editor;
}
@@ -22,7 +23,7 @@ export declare class Editor extends EventEmitter<EditorEvents> {
/**
* Returns the editor storage.
*/
- get storage(): Record<string, any>;
+ get storage(): EditorStorage;
/**
* An object of all registered commands.
*/

View File

@@ -17,7 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Node, mergeAttributes, findChildren, Editor } from "@tiptap/core";
import { Node, mergeAttributes, findChildren } from "@tiptap/core";
import { Attribute } from "@tiptap/core";
import { createSelectionBasedNodeView } from "../react";
import { AttachmentComponent } from "./component";
@@ -27,19 +27,13 @@ export type AttachmentType = "image" | "file" | "camera";
export interface AttachmentOptions {
types: string[];
HTMLAttributes: Record<string, unknown>;
onDownloadAttachment: (editor: Editor, attachment: Attachment) => boolean;
onOpenAttachmentPicker: (editor: Editor, type: AttachmentType) => boolean;
onPreviewAttachment: (editor: Editor, attachment: Attachment) => boolean;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
attachment: {
openAttachmentPicker: (type: AttachmentType) => ReturnType;
insertAttachment: (attachment: Attachment) => ReturnType;
removeAttachment: () => ReturnType;
downloadAttachment: (attachment: Attachment) => ReturnType;
previewAttachment: (options: Attachment) => ReturnType;
updateAttachment: (
attachment: Partial<Attachment>,
options: {
@@ -62,10 +56,7 @@ export const AttachmentNode = Node.create<AttachmentOptions>({
addOptions() {
return {
types: [this.name],
HTMLAttributes: {},
onDownloadAttachment: () => false,
onOpenAttachmentPicker: () => false,
onPreviewAttachment: () => false
HTMLAttributes: {}
};
},
@@ -139,16 +130,6 @@ export const AttachmentNode = Node.create<AttachmentOptions>({
({ commands }) => {
return commands.deleteSelection();
},
downloadAttachment:
(attachment) =>
({ editor }) => {
return this.options.onDownloadAttachment(editor, attachment);
},
openAttachmentPicker:
(type: AttachmentType) =>
({ editor }) => {
return this.options.onOpenAttachmentPicker(editor, type);
},
updateAttachment:
(attachment, options) =>
({ state, tr, dispatch }) => {
@@ -174,20 +155,14 @@ export const AttachmentNode = Node.create<AttachmentOptions>({
tr.setMeta("addToHistory", false);
if (dispatch) dispatch(tr);
return true;
},
previewAttachment:
(attachment) =>
({ editor }) => {
if (!this.options.onPreviewAttachment) return false;
return this.options.onPreviewAttachment(editor, attachment);
}
};
},
addKeyboardShortcuts() {
return {
"Mod-Shift-A": () => this.editor.commands.openAttachmentPicker("file")
"Mod-Shift-A": () =>
this.editor.storage.openAttachmentPicker?.("file") || true
};
}

View File

@@ -17,23 +17,35 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Node, NodeWithPos } from "@tiptap/core";
import { Extension, NodeWithPos } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
import { nanoid } from "nanoid";
import { getChangedNodes } from "../../utils/prosemirror";
const types: { [name: string]: boolean } = {
heading: true,
paragraph: true
};
const BLOCK_ID_TYPES = [
"paragraph",
"heading",
"blockquote",
"bulletList",
"orderedList",
"taskItem",
"taskList",
"table",
"codeblock",
"image",
"outlineList",
"mathBlock",
"webclip",
"embed"
];
export const BlockId = Node.create({
export const BlockId = Extension.create({
name: "blockId",
addGlobalAttributes() {
return [
{
types: Object.keys(types),
types: BLOCK_ID_TYPES,
attributes: {
blockId: {
default: null,
@@ -52,44 +64,37 @@ export const BlockId = Node.create({
}
];
},
addProseMirrorPlugins() {
return [
new Plugin({
appendTransaction: (transactions, oldState, newState) => {
// no changes
if (newState.doc === oldState.doc) {
return;
}
const tr = newState.tr;
const isDocChanged = transactions.some((tr) => tr.docChanged);
if (!isDocChanged) return null;
const blockIds = new Set<string>();
const blocksWithoutBlockId: NodeWithPos[] = [];
for (const tr of transactions) {
blocksWithoutBlockId.push(
...getChangedNodes(tr, {
descend: false,
predicate: (n) => {
const shouldInclude =
n.isBlock &&
(!n.attrs.blockId || blockIds.has(n.attrs.blockId));
if (n.attrs.blockId) blockIds.add(n.attrs.blockId);
return shouldInclude;
}
})
);
newState.tr.doc.forEach((n, offset) => {
if (
n.isBlock &&
BLOCK_ID_TYPES.includes(n.type.name) &&
!n.attrs.blockId
)
blocksWithoutBlockId.push({ node: n, pos: offset });
});
if (blocksWithoutBlockId.length > 0) {
console.log(blocksWithoutBlockId);
const { tr } = newState;
for (const { node, pos } of blocksWithoutBlockId) {
const id = nanoid(8);
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
blockId: id
});
}
return tr;
}
console.log(blocksWithoutBlockId);
for (const { node, pos } of blocksWithoutBlockId) {
const id = nanoid(8);
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
blockId: id
});
}
return tr;
return null;
}
})
];

View File

@@ -1,5 +1,24 @@
import { BlockId } from "./blockid";
/*
This file is part of the Notesnook project (https://notesnook.com/)
export * from "./blockid";
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { BlockId } from "./block-id";
export * from "./block-id";
export default BlockId;

View File

@@ -27,36 +27,9 @@ import { clipboardTextParser } from "./clipboard-text-parser";
import { clipboardTextSerializer } from "./clipboard-text-serializer";
import { EditorView } from "prosemirror-view";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
clipboard: {
copyToClipboard: (text: string) => ReturnType;
};
}
}
export type ClipboardOptions = {
copyToClipboard: (text: string) => void;
};
export const Clipboard = Extension.create({
name: "clipboard",
addOptions() {
return {
copyToClipboard: () => {}
};
},
addCommands() {
return {
copyToClipboard: (text: string) => (props) => {
this.options.copyToClipboard(text);
return true;
}
};
},
addProseMirrorPlugins() {
return [
new Plugin({

View File

@@ -163,7 +163,7 @@ export function CodeblockComponent(
bg: "transparent"
}}
onClick={() => {
editor.commands.copyToClipboard(node.textContent);
editor.storage.copyToClipboard?.(node.textContent);
start();
}}
title="Copy to clipboard"

View File

@@ -66,7 +66,7 @@ export function ImageComponent(
if (!align) align = textDirection ? "right" : "left";
const downloadOptions = useToolbarStore((store) => store.downloadOptions);
const isReadonly = !editor.current?.isEditable;
const isReadonly = !editor.isEditable;
const isSVG = !!mime && mime.includes("/svg");
const { height, width } = clampSize(
@@ -79,7 +79,7 @@ export function ImageComponent(
if (!inView) return;
if (src || !hash || bloburl) return;
(async function () {
const data = await editor.current?.storage
const data = await editor.storage
.getAttachmentData?.(node.attrs)
.catch(() => null);
if (typeof data !== "string" || !data) return; // TODO: show error
@@ -271,7 +271,7 @@ export function ImageComponent(
onDoubleClick={() => {
const { hash, filename, mime, size } = node.attrs;
if (!!hash && !!filename && !!mime && !!size)
editor.current?.commands.previewAttachment({
editor.storage.previewAttachment?.({
type: "image",
hash,
filename,

View File

@@ -199,7 +199,8 @@ export const ImageNode = Node.create<ImageOptions>({
addKeyboardShortcuts() {
return {
"Mod-Shift-I": () => this.editor.commands.openAttachmentPicker("image")
"Mod-Shift-I": () =>
this.editor.storage.openAttachmentPicker?.("image") || true
};
}
});

View File

@@ -0,0 +1,167 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
combineTransactionSteps,
findChildrenInRange,
getChangedRanges,
getMarksBetween,
NodeWithPos
} from "@tiptap/core";
import { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { find } from "linkifyjs";
type AutolinkOptions = {
type: MarkType;
validate?: (url: string) => boolean;
};
export function autolink(options: AutolinkOptions): Plugin {
return new Plugin({
key: new PluginKey("autolink"),
appendTransaction: (transactions, oldState, newState) => {
const docChanges =
transactions.some((transaction) => transaction.docChanged) &&
!oldState.doc.eq(newState.doc);
const preventAutolink = transactions.some((transaction) =>
transaction.getMeta("preventAutolink")
);
if (!docChanges || preventAutolink) {
return;
}
const { tr } = newState;
const transform = combineTransactionSteps(oldState.doc, [
...transactions
]);
const changes = getChangedRanges(transform);
changes.forEach(({ newRange }) => {
// Now lets see if we can add new links.
const nodesInChangedRanges = findChildrenInRange(
newState.doc,
newRange,
(node) => node.isTextblock
);
let textBlock: NodeWithPos | undefined;
let textBeforeWhitespace: string | undefined;
if (nodesInChangedRanges.length > 1) {
// Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter).
textBlock = nodesInChangedRanges[0];
textBeforeWhitespace = newState.doc.textBetween(
textBlock.pos,
textBlock.pos + textBlock.node.nodeSize,
undefined,
" "
);
} else if (
nodesInChangedRanges.length &&
// We want to make sure to include the block seperator argument to treat hard breaks like spaces.
newState.doc
.textBetween(newRange.from, newRange.to, " ", " ")
.endsWith(" ")
) {
textBlock = nodesInChangedRanges[0];
textBeforeWhitespace = newState.doc.textBetween(
textBlock.pos,
newRange.to,
undefined,
" "
);
}
if (textBlock && textBeforeWhitespace) {
const wordsBeforeWhitespace = textBeforeWhitespace
.split(" ")
.filter((s) => s !== "");
if (wordsBeforeWhitespace.length <= 0) {
return false;
}
const lastWordBeforeSpace =
wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1];
const lastWordAndBlockOffset =
textBlock.pos +
textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace);
if (!lastWordBeforeSpace) {
return false;
}
find(lastWordBeforeSpace)
.filter((link) => link.isLink)
// Calculate link position.
.map((link) => ({
...link,
from: lastWordAndBlockOffset + link.start + 1,
to: lastWordAndBlockOffset + link.end + 1
}))
// ignore link inside code mark
.filter((link) => {
if (!newState.schema.marks.code) {
return true;
}
return !newState.doc.rangeHasMark(
link.from,
link.to,
newState.schema.marks.code
);
})
// validate link
.filter((link) => {
if (options.validate) {
return options.validate(link.value);
}
return true;
})
// Add link mark.
.forEach((link) => {
if (
getMarksBetween(link.from, link.to, newState.doc).some(
(item) => item.mark.type === options.type
)
) {
return;
}
tr.addMark(
link.from,
link.to,
options.type.create({
href: link.href
})
);
});
}
});
if (!tr.steps.length) {
return;
}
return tr;
}
});
}

View File

@@ -0,0 +1,61 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getAttributes } from "@tiptap/core";
import { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
type ClickHandlerOptions = {
type: MarkType;
};
export function clickHandler(options: ClickHandlerOptions): Plugin {
return new Plugin({
key: new PluginKey("handleClickLink"),
props: {
handleClick: (view, pos, event) => {
if (event.button !== 0) {
return false;
}
const eventTarget = event.target as HTMLElement;
if (eventTarget.nodeName !== "A") {
return false;
}
const attrs = getAttributes(view.state, options.type.name);
const link = event.target as HTMLLinkElement;
const href = link?.href ?? attrs.href;
const target = link?.target ?? attrs.target;
if (link && href) {
if (view.editable) {
window.open(href, target);
}
return true;
}
return false;
}
}
});
}

View File

@@ -0,0 +1,65 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Editor } from "@tiptap/core";
import { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { find } from "linkifyjs";
type PasteHandlerOptions = {
editor: Editor;
type: MarkType;
};
export function pasteHandler(options: PasteHandlerOptions): Plugin {
return new Plugin({
key: new PluginKey("handlePasteLink"),
props: {
handlePaste: (view, event, slice) => {
const { state } = view;
const { selection } = state;
const { empty } = selection;
if (empty) {
return false;
}
let textContent = "";
slice.content.forEach((node) => {
textContent += node.textContent;
});
const link = find(textContent).find(
(item) => item.isLink && item.value === textContent
);
if (!textContent || !link) {
return false;
}
options.editor.commands.setMark(options.type, {
href: link.href
});
return true;
}
}
});
}

View File

@@ -16,13 +16,177 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { markInputRule, markPasteRule } from "@tiptap/core";
import TiptapLink from "@tiptap/extension-link";
import {
Command,
Mark,
getMarkType,
isMarkActive,
markInputRule,
markPasteRule,
mergeAttributes
} from "@tiptap/core";
import { Plugin, TextSelection } from "@tiptap/pm/state";
import { find, registerCustomProtocol, reset } from "linkifyjs";
import { autolink } from "./helpers/autolink";
import { clickHandler } from "./helpers/clickHandler";
import { pasteHandler } from "./helpers/pasteHandler";
import { ImageNode } from "../image";
import { selectionToOffset } from "../../utils/prosemirror";
export interface LinkProtocolOptions {
scheme: string;
optionalSlashes?: boolean;
}
export interface LinkOptions {
/**
* If enabled, it adds links as you type.
*/
autolink: boolean;
/**
* An array of custom protocols to be registered with linkifyjs.
*/
protocols: Array<LinkProtocolOptions | string>;
/**
* If enabled, links will be opened on click.
*/
openOnClick: boolean;
/**
* Adds a link to the current selection if the pasted content only contains an url.
*/
linkOnPaste: boolean;
/**
* A list of HTML attributes to be rendered.
*/
HTMLAttributes: Record<string, any>;
/**
* A validation function that modifies link verification for the auto linker.
* @param url - The url to be validated.
* @returns - True if the url is valid, false otherwise.
*/
validate?: (url: string) => boolean;
}
export type LinkAttributes = {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
title?: string | null;
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
link: {
/**
* Set a link mark
*/
setLink: (attributes: LinkAttributes) => ReturnType;
/**
* Toggle a link mark
*/
toggleLink: (attributes: LinkAttributes) => ReturnType;
/**
* Unset a link mark
*/
unsetLink: () => ReturnType;
};
}
}
const linkRegex = /(?:__|[*#])|\[(.*?)\]\(.*?\)/gm;
const regExp = /(?:__|[*#])|\[.*?\]\((.*?)\)/gm;
export const Link = Mark.create<LinkOptions>({
name: "link",
priority: 1000,
keepOnSplit: false,
onCreate() {
this.options.protocols.forEach((protocol) => {
if (typeof protocol === "string") {
registerCustomProtocol(protocol);
return;
}
registerCustomProtocol(protocol.scheme, protocol.optionalSlashes);
});
},
onDestroy() {
reset();
},
inclusive() {
return this.options.autolink;
},
addOptions() {
return {
openOnClick: true,
linkOnPaste: true,
autolink: true,
protocols: [],
HTMLAttributes: {
target: "_blank",
rel: "noopener noreferrer nofollow",
class: null,
title: null
},
validate: undefined
};
},
addAttributes() {
return {
href: {
default: null
},
target: {
default: this.options.HTMLAttributes.target
},
rel: {
default: this.options.HTMLAttributes.rel
},
class: {
default: this.options.HTMLAttributes.class
},
title: {
default: this.options.HTMLAttributes.title
}
};
},
parseHTML() {
return [{ tag: 'a[href]:not([href *= "javascript:" i])' }];
},
renderHTML({ HTMLAttributes }) {
return [
"a",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0
];
},
addCommands() {
return {
setLink: (attributes) => insertLink(attributes, false),
toggleLink: (attributes) => insertLink(attributes, true),
unsetLink:
() =>
({ chain }) => {
return chain()
.unsetMark(this.name, { extendEmptyMarkRange: true })
.setMeta("preventAutolink", true)
.run();
}
};
},
export const Link = TiptapLink.extend({
addInputRules() {
return [
...(this.parent?.() || []),
@@ -37,6 +201,7 @@ export const Link = TiptapLink.extend({
})
];
},
addPasteRules() {
return [
...(this.parent?.() || []),
@@ -48,7 +213,151 @@ export const Link = TiptapLink.extend({
href: regExp.exec(match[0])?.[1]
};
}
}),
markPasteRule({
find: (text) =>
find(text)
.filter((link) => {
if (this.options.validate) {
return this.options.validate(link.value);
}
return true;
})
.filter((link) => link.isLink)
.map((link) => ({
text: link.value,
index: link.start,
data: link
})),
type: this.type,
getAttributes: (match, pasteEvent) => {
const html = pasteEvent?.clipboardData?.getData("text/html");
const hrefRegex = /href="([^"]*)"/;
const existingLink = html?.match(hrefRegex);
if (existingLink) {
return {
href: existingLink[1]
};
}
return {
href: match.data?.href
};
}
})
];
},
addProseMirrorPlugins() {
const plugins: Plugin[] = [];
if (this.options.autolink) {
plugins.push(
autolink({
type: this.type,
validate: this.options.validate
})
);
}
if (this.options.openOnClick) {
plugins.push(
clickHandler({
type: this.type
})
);
}
if (this.options.linkOnPaste) {
plugins.push(
pasteHandler({
editor: this.editor,
type: this.type
})
);
}
return plugins;
}
});
const insertLink: (attributes: LinkAttributes, toggle?: boolean) => Command =
(attributes, toggle) =>
({ chain, editor }) => {
let commandChain = chain();
const offset = selectionToOffset(editor.state);
if (!offset) return false;
const { from, to, node } = offset;
const isSelection = !editor.state.selection.empty;
const isEditing = isMarkActive(editor.state, Link.name);
const isImage = node?.type.name === ImageNode.name;
if (isEditing && node) {
if (!isImage) {
const markType = getMarkType(Link.name, editor.schema);
commandChain = commandChain.command(({ tr }) => {
tr.removeMark(from, to, markType);
tr.insertText(
attributes.title || node.textContent,
tr.mapping.map(from),
tr.mapping.map(to)
);
tr.setSelection(
TextSelection.create(
tr.doc,
tr.mapping.map(from),
tr.mapping.map(to)
)
);
return true;
});
}
return commandChain
.extendMarkRange("link")
.setMark(Link.name, attributes)
.command(({ tr }) => {
tr.setSelection(
TextSelection.create(
tr.doc,
tr.mapping.map(editor.state.selection.from),
tr.mapping.map(editor.state.selection.to)
)
);
return true;
})
.focus(undefined, { scrollIntoView: true })
.run();
}
commandChain = toggle
? commandChain.toggleMark(Link.name, attributes, {
extendEmptyMarkRange: true
})
: commandChain.extendMarkRange(Link.name).setMark(Link.name, attributes);
if (!isImage)
commandChain = commandChain.insertContent(
attributes.title || attributes.href
);
if (!isSelection && !isImage) {
commandChain = commandChain.command(({ tr }) => {
tr.insertText(" ", tr.mapping.map(to));
tr.removeMark(
tr.mapping.map(to) - 1,
tr.mapping.map(to),
getMarkType(Link.name, editor.schema)
);
return true;
});
}
return commandChain.focus().setMeta("preventAutolink", true).run();
};

View File

@@ -1,21 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export * from "./open-link";
export { OpenLink as default } from "./open-link";

View File

@@ -1,51 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Extension } from "@tiptap/core";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
openlink: {
/**
* Open a link in browser
*/
openLink: (url: string) => ReturnType;
};
}
}
export type OpenLinkOptions = {
onOpenLink: (url: string) => boolean;
};
export const OpenLink = Extension.create<OpenLinkOptions>({
name: "openlink",
addOptions() {
return {
onOpenLink: () => false
};
},
addCommands() {
return {
openLink: (url: string) => () => {
return this.options.onOpenLink(url);
}
};
}
});

View File

@@ -50,16 +50,13 @@ export class ReactNodeView<P extends ReactNodeViewProps> implements NodeView {
contentDOM: HTMLElement | undefined;
node: PMNode;
isDragging = false;
portalProviderAPI: PortalProviderAPI;
constructor(
node: PMNode,
protected readonly editor: Editor,
protected readonly getPos: GetPosNode,
protected readonly portalProviderAPI: PortalProviderAPI,
protected readonly options: ReactNodeViewOptions<P>
) {
this.portalProviderAPI = editor.storage
.portalProviderAPI as PortalProviderAPI;
this.node = node;
}
@@ -482,11 +479,18 @@ export function createNodeView<TProps extends ReactNodeViewProps>(
) {
return ({ node, getPos, editor }: NodeViewRendererProps) => {
const _getPos = () => (typeof getPos === "boolean" ? -1 : getPos());
if (!editor.storage.portalProviderAPI) return {};
return new ReactNodeView<TProps>(node, editor as Editor, _getPos, {
...options,
component
}).init();
return new ReactNodeView<TProps>(
node,
editor as Editor,
_getPos,
editor.storage.portalProviderAPI,
{
...options,
component
}
).init();
};
}

View File

@@ -18,12 +18,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { PropsWithChildren, useContext } from "react";
import {
createPortal,
unstable_renderSubtreeIntoContainer,
unmountComponentAtNode
} from "react-dom";
import { createPortal } from "react-dom";
import { EventDispatcher } from "./event-dispatcher";
import { Root, createRoot } from "react-dom/client";
export type BasePortalProviderProps = PropsWithChildren<unknown>;
@@ -33,12 +30,8 @@ export type PortalRendererState = {
portals: Portals;
};
type MountedPortal = {
children: () => React.ReactChild | null;
};
export class PortalProviderAPI extends EventDispatcher {
portals: Map<HTMLElement, MountedPortal> = new Map();
portals: Map<HTMLElement, Root> = new Map();
context?: PortalRenderer;
constructor() {
@@ -56,17 +49,17 @@ export class PortalProviderAPI extends EventDispatcher {
) {
if (!this.context) return;
this.portals.set(container, {
children
});
const wrappedChildren = children() as JSX.Element;
// const wrappedChildren = children() as JSX.Element;
unstable_renderSubtreeIntoContainer(
this.context,
wrappedChildren,
container,
callback
);
// unstable_renderSubtreeIntoContainer(
// this.context,
// wrappedChildren,
// container,
// callback
// );
const root = this.portals.get(container) || createRoot(container);
root.render(children());
this.portals.set(container, root);
}
// TODO: until https://product-fabric.atlassian.net/browse/ED-5013
@@ -75,6 +68,7 @@ export class PortalProviderAPI extends EventDispatcher {
forceUpdate() {}
remove(container: HTMLElement) {
const root = this.portals.get(container);
this.portals.delete(container);
// There is a race condition that can happen caused by Prosemirror vs React,
@@ -84,7 +78,8 @@ export class PortalProviderAPI extends EventDispatcher {
// Both Prosemirror and React remove the elements asynchronously, and in edge
// cases Prosemirror beats React
try {
unmountComponentAtNode(container);
root?.unmount();
// unmountComponentAtNode(container);
} catch (error) {
// IGNORE console.error(error);
}

View File

@@ -34,6 +34,7 @@ import {
import { ReactNodeView } from "./react-node-view";
import { Editor, NodeViewRendererProps } from "@tiptap/core";
import { EmotionThemeProvider } from "@notesnook/theme";
import { PortalProviderAPI } from "./react-portal-provider";
/**
* A ReactNodeView that handles React components sensitive
@@ -72,9 +73,10 @@ export class SelectionBasedNodeView<
node: PMNode,
editor: Editor,
getPos: GetPosNode,
portalProviderAPI: PortalProviderAPI,
options: ReactNodeViewOptions<P>
) {
super(node, editor, getPos, options);
super(node, editor, getPos, portalProviderAPI, options);
this.updatePos();
@@ -277,9 +279,16 @@ export function createSelectionBasedNodeView<
) {
return ({ node, getPos, editor }: NodeViewRendererProps) => {
const _getPos = () => (typeof getPos === "boolean" ? -1 : getPos());
return new SelectionBasedNodeView(node, editor as Editor, _getPos, {
...options,
component
}).init();
if (!editor.storage.portalProviderAPI) return {};
return new SelectionBasedNodeView(
node,
editor as Editor,
_getPos,
editor.storage.portalProviderAPI,
{
...options,
component
}
).init();
};
}

View File

@@ -82,6 +82,8 @@ export function TableComponent(props: ReactNodeViewProps) {
}
export function TableNodeView(editor: TiptapEditor) {
if (!editor.storage.portalProviderAPI) return;
const api = editor.storage.portalProviderAPI;
class TableNode
extends ReactNodeView<ReactNodeViewProps<unknown>>
implements NodeView
@@ -91,6 +93,7 @@ export function TableNodeView(editor: TiptapEditor) {
node,
editor,
() => 0, // todo
api,
{
component: TableComponent,
shouldUpdate: (prev, next) => {
@@ -161,9 +164,9 @@ function TableRowToolbar(props: TableToolbarProps) {
}
}
editor.current?.on("selectionUpdate", onSelectionUpdate);
editor.on("selectionUpdate", onSelectionUpdate);
return () => {
editor.current?.off("selectionUpdate", onSelectionUpdate);
editor.off("selectionUpdate", onSelectionUpdate);
};
}, [textDirection]);
@@ -236,9 +239,9 @@ function TableColumnToolbar(props: TableToolbarProps) {
columnToolsRef.current.style.top = `${pos.top}px`;
}
editor.current?.on("selectionUpdate", onSelectionUpdate);
editor.on("selectionUpdate", onSelectionUpdate);
return () => {
editor.current?.off("selectionUpdate", onSelectionUpdate);
editor.off("selectionUpdate", onSelectionUpdate);
};
}, []);

View File

@@ -36,16 +36,16 @@ export function TaskItemComponent(
const isMobile = useIsMobile();
const toggle = useCallback(() => {
if (!editor.isEditable || !editor.current) return false;
if (!editor.isEditable || !editor) return false;
const { empty, from, to } = editor.current.state.selection;
const { empty, from, to } = editor.state.selection;
const selectedTaskItems = findChildrenInRange(
editor.current.state.doc,
editor.state.doc,
{ from, to },
(node) => node.type.name === TaskItemNode.name
);
if (!empty && selectedTaskItems.findIndex((a) => a.node === node) > -1) {
editor.current.commands.command(({ tr }) => {
editor.commands.command(({ tr }) => {
for (const { pos } of selectedTaskItems) {
tr.setNodeMarkup(pos, null, { checked: !checked });
}
@@ -152,12 +152,12 @@ export function TaskItemComponent(
cursor: "pointer"
}}
onClick={() => {
if (!editor.current) return;
if (!editor) return;
const pos = getPos();
// we need to get a fresh instance of the task list instead
// of using the one we got via props.
const node = editor.current.state.doc.nodeAt(pos);
const node = editor.state.doc.nodeAt(pos);
if (!node) return;
editor.commands.command(({ tr }) => {

View File

@@ -97,7 +97,7 @@ export function TaskListComponent(
}}
onClick={() => {
const parentPos = getPos();
editor.current?.commands.command(({ tr }) => {
editor.commands.command(({ tr }) => {
const node = tr.doc.nodeAt(parentPos);
if (!node) return false;
toggleChildren(tr, node, !checked, parentPos);
@@ -125,8 +125,8 @@ export function TaskListComponent(
onChange={(e) => {
e.target.value = replaceDateTime(
e.target.value,
editor.current?.storage.dateFormat,
editor.current?.storage.timeFormat
editor.storage.dateFormat,
editor.storage.timeFormat
);
updateAttributes(
{ title: e.target.value },
@@ -146,7 +146,7 @@ export function TaskListComponent(
}}
onClick={() => {
const parentPos = getPos();
editor.current?.commands.command(({ tr }) => {
editor.commands.command(({ tr }) => {
const node = tr.doc.nodeAt(parentPos);
if (!node) return false;
const toggleState = !node.attrs.readonly;
@@ -177,7 +177,7 @@ export function TaskListComponent(
}}
onClick={() => {
const pos = getPos();
editor.current
editor
?.chain()
.focus()
.command(({ tr }) => {
@@ -197,7 +197,7 @@ export function TaskListComponent(
onClick={() => {
const pos = getPos();
editor.current
editor
?.chain()
.focus()
.command(({ tr }) => {

View File

@@ -37,15 +37,7 @@ export const useEditor = (
options: Partial<EditorOptions> = {},
deps: DependencyList = []
) => {
const editor = useMemo<Editor>(() => {
const instance = new Editor(options);
if (instance && typeof instance.current === "undefined") {
Object.defineProperty(instance, "current", {
get: () => editorRef.current
});
}
return instance;
}, []);
const editor = useMemo<Editor>(() => new Editor(options), []);
const forceUpdate = useForceUpdate();
const editorRef = useRef<TiptapEditor>(editor);

View File

@@ -26,7 +26,7 @@ import CharacterCount from "@tiptap/extension-character-count";
import { Code } from "@tiptap/extension-code";
import Color from "@tiptap/extension-color";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import { Link } from "./extensions/link";
import { Link, LinkAttributes } from "./extensions/link";
import Placeholder from "@tiptap/extension-placeholder";
import Subscript from "@tiptap/extension-subscript";
import Superscript from "@tiptap/extension-superscript";
@@ -42,7 +42,8 @@ import "./extensions";
import {
Attachment,
AttachmentNode,
AttachmentOptions
AttachmentOptions,
AttachmentType
} from "./extensions/attachment";
import BulletList from "./extensions/bullet-list";
import { CodeBlock } from "./extensions/code-block";
@@ -57,13 +58,13 @@ import { KeepInView } from "./extensions/keep-in-view";
import { KeyMap } from "./extensions/key-map";
import { ListItem } from "./extensions/list-item";
import { MathBlock, MathInline } from "./extensions/math";
import { OpenLink, OpenLinkOptions } from "./extensions/open-link";
import OrderedList from "./extensions/ordered-list";
import { OutlineList } from "./extensions/outline-list";
import { OutlineListItem } from "./extensions/outline-list-item";
import { Paragraph } from "./extensions/paragraph";
import {
NodeViewSelectionNotifier,
PortalProviderAPI,
usePortalProvider
} from "./extensions/react";
import { SearchReplace } from "./extensions/search-replace";
@@ -79,7 +80,7 @@ import Toolbar from "./toolbar";
import { useToolbarStore } from "./toolbar/stores/toolbar-store";
import { DownloadOptions } from "./utils/downloader";
import { Heading } from "./extensions/heading";
import Clipboard, { ClipboardOptions } from "./extensions/clipboard";
import Clipboard from "./extensions/clipboard";
import Blockquote from "./extensions/blockquote";
import { Quirks } from "./extensions/quirks";
import { LIST_NODE_TYPES } from "./utils/node-types";
@@ -88,6 +89,25 @@ import CheckListItem from "./extensions/check-list-item";
import { Callout } from "./extensions/callout";
import BlockId from "./extensions/block-id";
interface TiptapStorage {
portalProviderAPI?: PortalProviderAPI;
dateFormat?: DateTimeOptions["dateFormat"];
timeFormat?: DateTimeOptions["timeFormat"];
openLink?: (url: string) => void;
downloadAttachment?: (attachment: Attachment) => void;
openAttachmentPicker?: (type: AttachmentType) => void;
previewAttachment?: (attachment: Attachment) => void;
copyToClipboard?: (text: string, html?: string) => void;
createInternalLink?: (
attributes?: LinkAttributes
) => Promise<LinkAttributes | undefined>;
getAttachmentData: (attachment: Attachment) => Promise<string | undefined>;
}
declare module "@tiptap/core" {
interface EditorStorage extends TiptapStorage {}
}
declare global {
// eslint-disable-next-line no-var
var keyboardShown: boolean;
@@ -106,16 +126,13 @@ const CoreExtensions = Object.entries(TiptapCoreExtensions)
.map(([, extension]) => extension);
export type TiptapOptions = EditorOptions &
Omit<AttachmentOptions, "HTMLAttributes"> &
Omit<WebClipOptions, "HTMLAttributes"> &
Omit<ImageOptions, "HTMLAttributes"> &
DateTimeOptions &
ClipboardOptions &
OpenLinkOptions & {
Omit<TiptapStorage, "portalProviderAPI"> & {
downloadOptions?: DownloadOptions;
isMobile?: boolean;
doubleSpacedLines?: boolean;
getAttachmentData: (attachment: Attachment) => Promise<string | undefined>;
};
const useTiptap = (
@@ -123,18 +140,20 @@ const useTiptap = (
deps: React.DependencyList = []
) => {
const {
doubleSpacedLines = true,
isMobile,
onDownloadAttachment,
onOpenAttachmentPicker,
onPreviewAttachment,
onOpenLink,
getAttachmentData,
downloadAttachment,
openAttachmentPicker,
previewAttachment,
openLink,
onBeforeCreate,
downloadOptions,
dateFormat,
timeFormat,
copyToClipboard,
createInternalLink,
doubleSpacedLines = true,
isMobile,
downloadOptions,
editorProps,
...restOptions
} = options;
@@ -245,9 +264,7 @@ const useTiptap = (
allowTableNodeSelection: true,
cellMinWidth: 50
}),
Clipboard.configure({
copyToClipboard
}),
Clipboard,
TableRow,
TableCell,
TableHeader,
@@ -262,15 +279,9 @@ const useTiptap = (
Placeholder.configure({
placeholder: "Start writing your note..."
}),
OpenLink.configure({
onOpenLink
}),
ImageNode.configure({ allowBase64: true }),
EmbedNode,
AttachmentNode.configure({
onDownloadAttachment,
onOpenAttachmentPicker,
onPreviewAttachment,
types: [AttachmentNode.name, ImageNode.name, WebClipNode.name]
}),
OutlineListItem,
@@ -333,24 +344,33 @@ const useTiptap = (
editor.storage.portalProviderAPI = PortalProviderAPI;
editor.storage.dateFormat = dateFormat;
editor.storage.timeFormat = timeFormat;
editor.storage.openLink = openLink;
editor.storage.downloadAttachment = downloadAttachment;
editor.storage.openAttachmentPicker = openAttachmentPicker;
editor.storage.previewAttachment = previewAttachment;
editor.storage.copyToClipboard = copyToClipboard;
editor.storage.createInternalLink = createInternalLink;
editor.storage.getAttachmentData = getAttachmentData;
if (onBeforeCreate) onBeforeCreate({ editor });
},
injectCSS: false,
parseOptions: { preserveWhitespace: true }
}),
[
onPreviewAttachment,
onDownloadAttachment,
onOpenAttachmentPicker,
previewAttachment,
downloadAttachment,
openAttachmentPicker,
getAttachmentData,
PortalProviderAPI,
onBeforeCreate,
onOpenLink,
openLink,
dateFormat,
timeFormat,
editorProps,
copyToClipboard
copyToClipboard,
createInternalLink
]
);

View File

@@ -31,7 +31,7 @@ export type ToolButtonProps = ButtonProps & {
icon: IconNames;
iconColor?: SchemeColors;
iconSize?: keyof Theme["iconSizes"] | number;
toggled: boolean;
toggled?: boolean;
buttonRef?: React.RefObject<HTMLButtonElement>;
variant?: ToolButtonVariant;
};

View File

@@ -68,14 +68,14 @@ export function HoverPopupHandler(props: FloatingMenuProps) {
hoverTimeoutId.current = setTimeout(
() => {
const PopupHandler = handlers.find((h) => h.isActive(element));
if (!PopupHandler || !editor.current) return;
if (!PopupHandler || !editor) return;
const { popup: Popup } = PopupHandler;
const pos = editor.current.view.posAtDOM(element, 0);
const pos = editor.view.posAtDOM(element, 0);
if (pos < 0) return;
const node = editor.current.view.state.doc.nodeAt(pos);
const node = editor.view.state.doc.nodeAt(pos);
if (!node) return;
const hidePopup = showPopup({
@@ -95,7 +95,8 @@ export function HoverPopupHandler(props: FloatingMenuProps) {
target: element,
align: "center",
location: "top",
isTargetAbsolute: true
isTargetAbsolute: true,
yOffset: -30
}
});
activePopup.current = { element, hide: hidePopup };

View File

@@ -121,7 +121,8 @@ import {
mdiCheckboxMultipleBlankOutline,
mdiCheckboxMultipleMarked,
mdiFormatFloatLeft,
mdiMessageOutline
mdiMessageOutline,
mdiVectorLink
} from "@mdi/js";
export const Icons = {
@@ -148,6 +149,7 @@ export const Icons = {
bulletList: mdiFormatListBulleted,
highlight: mdiFormatColorHighlight,
textColor: mdiFormatColorText,
noteLink: mdiVectorLink,
link: mdiLinkPlus,
linkRemove: mdiLinkOff,
openLink: mdiOpenInNew,

View File

@@ -48,16 +48,10 @@ export function CellProperties(props: CellPropertiesProps) {
expanded={true}
color={attributes.backgroundColor}
onChange={(color) =>
editor.current?.commands.setCellAttribute(
"backgroundColor",
color
)
editor.commands.setCellAttribute("backgroundColor", color)
}
onClear={() =>
editor.current?.commands.setCellAttribute(
"backgroundColor",
undefined
)
editor.commands.setCellAttribute("backgroundColor", undefined)
}
/>
</Tab>
@@ -72,11 +66,9 @@ export function CellProperties(props: CellPropertiesProps) {
expanded={true}
color={attributes.color}
onChange={(color) =>
editor.current?.commands.setCellAttribute("color", color)
}
onClear={() =>
editor.current?.commands.setCellAttribute("color", undefined)
editor.commands.setCellAttribute("color", color)
}
onClear={() => editor.commands.setCellAttribute("color", undefined)}
/>
</Tab>
<Tab
@@ -94,13 +86,10 @@ export function CellProperties(props: CellPropertiesProps) {
expanded={true}
color={attributes.borderColor}
onChange={(color) =>
editor.current?.commands.setCellAttribute("borderColor", color)
editor.commands.setCellAttribute("borderColor", color)
}
onClear={() =>
editor.current?.commands.setCellAttribute(
"borderColor",
undefined
)
editor.commands.setCellAttribute("borderColor", undefined)
}
/>
</Tab>

View File

@@ -47,7 +47,7 @@ export function ImageProperties(props: ImagePropertiesProps) {
sx: { mr: 1 }
}}
onChange={(e) => {
editor.current?.commands.setImageSize({
editor.commands.setImageSize({
width: e.target.valueAsNumber,
height: aspectRatio
? e.target.valueAsNumber / aspectRatio
@@ -60,7 +60,7 @@ export function ImageProperties(props: ImagePropertiesProps) {
type="number"
value={height || 0}
onChange={(e) => {
editor.current?.commands.setImageSize({
editor.commands.setImageSize({
width: aspectRatio
? e.target.valueAsNumber * aspectRatio
: e.target.valueAsNumber,

View File

@@ -28,9 +28,16 @@ export type LinkPopupProps = {
isEditing?: boolean;
onDone: (link: LinkDefinition) => void;
onClose: () => void;
isImageActive?: boolean;
};
export function LinkPopup(props: LinkPopupProps) {
const { link: _link, isEditing = false, onDone, onClose } = props;
const {
link: _link = { title: "", href: "" },
isEditing = false,
onDone,
onClose,
isImageActive
} = props;
const link = useRefValue(_link);
return (
@@ -46,14 +53,17 @@ export function LinkPopup(props: LinkPopupProps) {
}}
>
<Flex sx={{ p: 1, flexDirection: "column", width: ["auto", 250] }}>
{!link.current?.isImage && (
{!isImageActive && (
<Input
type="text"
placeholder="Link text"
defaultValue={link.current?.text}
defaultValue={link.current?.title}
sx={{ mb: 1 }}
onChange={(e) =>
(link.current = { ...link.current, text: e.target.value })
(link.current = {
...link.current,
title: e.target.value
})
}
/>
)}

View File

@@ -40,7 +40,7 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
const search = useCallback(
(term: string) => {
editor.current?.commands.search(term, {
editor.commands.search(term, {
matchCase,
enableRegex,
matchWholeWord
@@ -121,7 +121,7 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
editor.current?.commands.moveToNextResult();
editor.commands.moveToNextResult();
}
}}
/>
@@ -219,7 +219,7 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
title="Previous match"
id="previousMatch"
icon="previousMatch"
onClick={() => editor.current?.commands.moveToPreviousResult()}
onClick={() => editor.commands.moveToPreviousResult()}
sx={{ mr: 0 }}
iconSize={"big"}
/>
@@ -228,7 +228,7 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
title="Next match"
id="nextMatch"
icon="nextMatch"
onClick={() => editor.current?.commands.moveToNextResult()}
onClick={() => editor.commands.moveToNextResult()}
sx={{ mr: 0 }}
iconSize={"big"}
/>
@@ -237,7 +237,7 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
title="Close"
id="close"
icon="close"
onClick={() => editor.current?.chain().focus().endSearch().run()}
onClick={() => editor.chain().focus().endSearch().run()}
sx={{ mr: 0 }}
iconSize={"big"}
/>
@@ -249,9 +249,7 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
title="Replace"
id="replace"
icon="replaceOne"
onClick={() =>
editor.current?.commands.replace(replaceText.current)
}
onClick={() => editor.commands.replace(replaceText.current)}
sx={{ mr: 0 }}
iconSize={18}
/>
@@ -260,9 +258,7 @@ export function SearchReplacePopup(props: SearchReplacePopupProps) {
title="Replace all"
id="replaceAll"
icon="replaceAll"
onClick={() =>
editor.current?.commands.replaceAll(replaceText.current)
}
onClick={() => editor.commands.replaceAll(replaceText.current)}
sx={{ mr: 0 }}
iconSize={18}
/>

View File

@@ -41,6 +41,10 @@ const tools: Record<ToolId, ToolDefinition> = {
icon: "strikethrough",
title: "Strikethrough"
},
addInternalLink: {
icon: "noteLink",
title: "Add bi-directional note link"
},
addLink: {
icon: "link",
title: "Link"
@@ -413,7 +417,7 @@ const defaultPresets: Record<"default" | "minimal", ToolbarDefinition> = {
["fontSize"],
["headings", "fontFamily"],
["checkList", "numberedList", "bulletList"],
["addLink"],
["addLink", "addInternalLink"],
["alignment", "textDirection"],
["clearformatting"]
],

View File

@@ -58,11 +58,11 @@ export function Toolbar(props: ToolbarProps) {
const toolbarTools = useMemo(
() =>
isMobile
? editor?.current?.isEditable
? editor.isEditable
? [...MOBILE_STATIC_TOOLBAR_GROUPS, ...tools]
: READONLY_MOBILE_STATIC_TOOLBAR_GROUPS
: [...STATIC_TOOLBAR_GROUPS, ...tools],
[tools, isMobile]
[tools, editor.isEditable, isMobile]
);
const setToolbarLocation = useToolbarStore(

View File

@@ -35,11 +35,7 @@ function AlignmentTool(props: AlignmentToolProps) {
<ToolButton
{...toolProps}
onClick={() => {
editor.current
?.chain()
.focus()
.setTextAlign(alignmentRef.current)
.run();
editor?.chain().focus().setTextAlign(alignmentRef.current).run();
}}
disabled={editor.isActive(CodeBlock.name)}
toggled={false}

View File

@@ -56,7 +56,7 @@ export function DownloadAttachment(props: ToolProps) {
findSelectedNode(editor, "image");
const attachment = (attachmentNode?.attrs || {}) as Attachment;
editor.current?.chain().focus().downloadAttachment(attachment).run();
editor.storage.downloadAttachment?.(attachment);
}}
/>
);
@@ -81,7 +81,7 @@ export function PreviewAttachment(props: ToolProps) {
findSelectedNode(editor, "image");
const attachment = (attachmentNode?.attrs || {}) as Attachment;
editor.current?.commands.previewAttachment(attachment);
editor.storage.previewAttachment?.(attachment);
}}
/>
);
@@ -93,7 +93,7 @@ export function RemoveAttachment(props: ToolProps) {
<ToolButton
{...props}
toggled={false}
onClick={() => editor.current?.chain().focus().removeAttachment().run()}
onClick={() => editor.chain().focus().removeAttachment().run()}
/>
);
}

View File

@@ -107,7 +107,7 @@ const horizontalRule = (editor: Editor): MenuItem => ({
title: "Horizontal rule",
icon: Icons.horizontalRule,
isChecked: editor?.isActive("horizontalRule"),
onClick: () => editor.current?.chain().focus().setHorizontalRule().run()
onClick: () => editor.chain().focus().setHorizontalRule().run()
});
const codeblock = (editor: Editor): MenuItem => ({
@@ -116,7 +116,7 @@ const codeblock = (editor: Editor): MenuItem => ({
title: "Code block",
icon: Icons.codeblock,
isChecked: editor?.isActive("codeBlock"),
onClick: () => editor.current?.chain().focus().toggleCodeBlock().run(),
onClick: () => editor.chain().focus().toggleCodeBlock().run(),
modifier: "Mod-Shift-C"
});
@@ -126,7 +126,7 @@ const blockquote = (editor: Editor): MenuItem => ({
title: "Quote",
icon: Icons.blockquote,
isChecked: editor?.isActive("blockQuote"),
onClick: () => editor.current?.chain().focus().toggleBlockquote().run(),
onClick: () => editor.chain().focus().toggleBlockquote().run(),
modifier: "Mod-Shift-B"
});
@@ -136,7 +136,7 @@ const mathblock = (editor: Editor): MenuItem => ({
title: "Math & formulas",
icon: Icons.mathBlock,
isChecked: editor?.isActive("mathBlock"),
onClick: () => editor.current?.chain().focus().insertMathBlock().run(),
onClick: () => editor.chain().focus().insertMathBlock().run(),
modifier: "Mod-Shift-M"
});
@@ -183,8 +183,7 @@ const image = (editor: Editor, isMobile: boolean): MenuItem => ({
type: "button",
title: "Upload from disk",
icon: Icons.upload,
onClick: () =>
editor.current?.chain().focus().openAttachmentPicker("image").run(),
onClick: () => editor.storage.openAttachmentPicker?.("image"),
modifier: "Mod-Shift-I"
},
{
@@ -193,8 +192,7 @@ const image = (editor: Editor, isMobile: boolean): MenuItem => ({
title: "Take a photo using camera",
icon: Icons.camera,
isHidden: !isMobile,
onClick: () =>
editor.current?.chain().focus().openAttachmentPicker("camera").run()
onClick: () => editor.storage.openAttachmentPicker?.("camera")
},
isMobile ? uploadImageFromURLMobile(editor) : uploadImageFromURL(editor)
]
@@ -215,7 +213,7 @@ const table = (editor: Editor): MenuItem => ({
component: (props) => (
<TablePopup
onInsertTable={(size) => {
editor.current
editor
?.chain()
.focus()
.insertTable({
@@ -249,7 +247,7 @@ const embedMobile = (editor: Editor): MenuItem => ({
title="Insert embed"
onClose={(embed) => {
if (!embed) return onClick?.();
editor.current?.chain().insertEmbed(embed).run();
editor.chain().insertEmbed(embed).run();
onClick?.();
}}
/>
@@ -273,7 +271,7 @@ const embedDesktop = (editor: Editor): MenuItem => ({
title="Insert embed"
onClose={(embed) => {
if (!embed) return hide();
editor.current?.chain().insertEmbed(embed).run();
editor.chain().insertEmbed(embed).run();
hide();
}}
/>
@@ -288,8 +286,7 @@ const attachment = (editor: Editor): MenuItem => ({
title: "Attachment",
icon: Icons.attachment,
isChecked: editor?.isActive("attachment"),
onClick: () =>
editor.current?.chain().focus().openAttachmentPicker("file").run(),
onClick: () => editor.storage.openAttachmentPicker?.("file"),
modifier: "Mod-Shift-A"
});
@@ -299,7 +296,7 @@ const tasklist = (editor: Editor): MenuItem => ({
title: "Task list",
icon: Icons.checkbox,
isChecked: editor?.isActive("taskList"),
onClick: () => editor.current?.chain().focus().toggleTaskList().run(),
onClick: () => editor.chain().focus().toggleTaskList().run(),
modifier: "Mod-Shift-T"
});
@@ -309,7 +306,7 @@ const outlinelist = (editor: Editor): MenuItem => ({
title: "Outline list",
icon: Icons.outlineList,
isChecked: editor?.isActive("outlineList"),
onClick: () => editor.current?.chain().focus().toggleOutlineList().run(),
onClick: () => editor.chain().focus().toggleOutlineList().run(),
modifier: "Mod-Shift-O"
});

View File

@@ -135,8 +135,8 @@ export function Highlight(props: ToolProps) {
title={"Background color"}
onColorChange={(color) =>
color
? editor.current?.chain().setHighlight(color).run()
: editor.current?.chain().unsetHighlight().run()
? editor.chain().focus().setHighlight(color).run()
: editor.chain().focus().unsetHighlight().run()
}
/>
);
@@ -152,8 +152,8 @@ export function TextColor(props: ToolProps) {
title="Text color"
onColorChange={(color) =>
color
? editor.current?.chain().setColor(color).run()
: editor.current?.chain().unsetColor().run()
? editor.chain().focus().setColor(color).run()
: editor.chain().focus().unsetColor().run()
}
/>
);

View File

@@ -54,11 +54,7 @@ export function EmbedAlignLeft(props: ToolProps) {
{...props}
toggled={false}
onClick={() =>
editor.current
?.chain()
.focus()
.setEmbedAlignment({ align: "left" })
.run()
editor?.chain().focus().setEmbedAlignment({ align: "left" }).run()
}
/>
);
@@ -71,11 +67,7 @@ export function EmbedAlignRight(props: ToolProps) {
{...props}
toggled={false}
onClick={() =>
editor.current
?.chain()
.focus()
.setEmbedAlignment({ align: "right" })
.run()
editor?.chain().focus().setEmbedAlignment({ align: "right" }).run()
}
/>
);
@@ -88,11 +80,7 @@ export function EmbedAlignCenter(props: ToolProps) {
{...props}
toggled={false}
onClick={() =>
editor.current
?.chain()
.focus()
.setEmbedAlignment({ align: "center" })
.run()
editor?.chain().focus().setEmbedAlignment({ align: "center" }).run()
}
/>
);
@@ -136,14 +124,14 @@ export function EmbedProperties(props: ToolProps) {
title="Embed properties"
onClose={(newEmbed) => {
if (!newEmbed) {
editor.current?.commands.setEmbedSize(embed);
editor.commands.setEmbedSize(embed);
} else if (newEmbed.src !== embed.src)
editor.current?.commands.setEmbedSource(newEmbed.src);
editor.commands.setEmbedSource(newEmbed.src);
setIsOpen(false);
}}
embed={embed}
onSizeChanged={(size) => editor.current?.commands.setEmbedSize(size)}
onSizeChanged={(size) => editor.commands.setEmbedSize(size)}
/>
</ResponsivePresenter>
</>

View File

@@ -49,25 +49,13 @@ export function FontSize(props: ToolProps) {
title="Font size"
disabled={editor.isActive(CodeBlock.name)}
onDecrease={() =>
editor.current
?.chain()
.focus()
.setFontSize(`${decreaseFontSize()}px`)
.run()
editor?.chain().focus().setFontSize(`${decreaseFontSize()}px`).run()
}
onIncrease={() => {
editor.current
?.chain()
.focus()
.setFontSize(`${increaseFontSize()}px`)
.run();
editor?.chain().focus().setFontSize(`${increaseFontSize()}px`).run();
}}
onReset={() =>
editor.current
?.chain()
.focus()
.setFontSize(`${defaultFontSize}px`)
.run()
editor?.chain().focus().setFontSize(`${defaultFontSize}px`).run()
}
value={fontSize || `${defaultFontSize}px`}
/>
@@ -108,8 +96,7 @@ function toMenuItems(editor: Editor, currentFontFamily: string): MenuItem[] {
type: "button",
title: font.title,
isChecked: font.id === currentFontFamily,
onClick: () =>
editor.current?.chain().focus().setFontFamily(font.id).run(),
onClick: () => editor.chain().focus().setFontFamily(font.id).run(),
styles: {
title: {
fontFamily: font.font

View File

@@ -66,7 +66,7 @@ function toMenuItems(
isChecked: level === currentHeadingLevel,
modifier: `Mod-Alt-${level}`,
onClick: () =>
editor.current
editor
?.chain()
.focus()
.updateAttributes("textStyle", { fontSize: null, fontStyle: null })
@@ -79,7 +79,7 @@ function toMenuItems(
title: "Paragraph",
isChecked: !currentHeadingLevel,
modifier: `Mod-Alt-0`,
onClick: () => editor.current?.chain().focus().setParagraph().run()
onClick: () => editor.chain().focus().setParagraph().run()
};
return [paragraph, ...menuItems];
}

View File

@@ -66,11 +66,7 @@ export function ImageAlignLeft(props: ToolProps) {
{...props}
toggled={!align || align === "left"}
onClick={() =>
editor.current
?.chain()
.focus()
.setImageAlignment({ align: "left" })
.run()
editor?.chain().focus().setImageAlignment({ align: "left" }).run()
}
/>
);
@@ -88,11 +84,7 @@ export function ImageAlignRight(props: ToolProps) {
{...props}
toggled={align === "right"}
onClick={() =>
editor.current
?.chain()
.focus()
.setImageAlignment({ align: "right" })
.run()
editor?.chain().focus().setImageAlignment({ align: "right" }).run()
}
/>
);
@@ -110,11 +102,7 @@ export function ImageAlignCenter(props: ToolProps) {
{...props}
toggled={align === "center"}
onClick={() =>
editor.current
?.chain()
.focus()
.setImageAlignment({ align: "center" })
.run()
editor?.chain().focus().setImageAlignment({ align: "center" }).run()
}
/>
);

View File

@@ -84,6 +84,7 @@ import {
} from "./embed";
import {
AddLink,
AddInternalLink,
EditLink,
RemoveLink,
LinkSettings,
@@ -108,6 +109,7 @@ const tools = {
subscript: Subscript,
superscript: Superscript,
clearformatting: ClearFormatting,
addInternalLink: AddInternalLink,
addLink: AddLink,
editLink: EditLink,
removeLink: RemoveLink,

View File

@@ -28,7 +28,7 @@ export function Italic(props: ToolProps) {
<ToolButton
{...props}
toggled={editor.isActive("italic")}
onClick={() => editor.current?.chain().focus().toggleItalic().run()}
onClick={() => editor.chain().focus().toggleItalic().run()}
/>
);
}
@@ -39,7 +39,7 @@ export function Strikethrough(props: ToolProps) {
<ToolButton
{...props}
toggled={editor.isActive("strike")}
onClick={() => editor.current?.chain().focus().toggleStrike().run()}
onClick={() => editor.chain().focus().toggleStrike().run()}
/>
);
}
@@ -50,7 +50,7 @@ export function Underline(props: ToolProps) {
<ToolButton
{...props}
toggled={editor.isActive("underline")}
onClick={() => editor.current?.chain().focus().toggleUnderline().run()}
onClick={() => editor.chain().focus().toggleUnderline().run()}
/>
);
}
@@ -63,7 +63,7 @@ export function Code(props: ToolProps) {
{...props}
toggled={editor.isActive("code")}
disabled={editor.isActive(CodeBlock.name)}
onClick={() => editor.current?.chain().focus().toggleCode().run()}
onClick={() => editor.chain().focus().toggleCode().run()}
/>
);
}
@@ -75,7 +75,7 @@ export function Bold(props: ToolProps) {
<ToolButton
{...props}
toggled={editor.isActive("bold")}
onClick={() => editor.current?.chain().focus().toggleBold().run()}
onClick={() => editor.chain().focus().toggleBold().run()}
/>
);
}
@@ -87,7 +87,7 @@ export function Subscript(props: ToolProps) {
{...props}
toggled={editor.isActive("subscript")}
disabled={editor.isActive(CodeBlock.name)}
onClick={() => editor.current?.chain().focus().toggleSubscript().run()}
onClick={() => editor.chain().focus().toggleSubscript().run()}
/>
);
}
@@ -99,7 +99,7 @@ export function Superscript(props: ToolProps) {
{...props}
toggled={editor.isActive("superscript")}
disabled={editor.isActive(CodeBlock.name)}
onClick={() => editor.current?.chain().focus().toggleSuperscript().run()}
onClick={() => editor.chain().focus().toggleSuperscript().run()}
/>
);
}
@@ -111,7 +111,7 @@ export function ClearFormatting(props: ToolProps) {
{...props}
toggled={false}
onClick={() =>
editor.current
editor
?.chain()
.focus()
.clearNodes()
@@ -131,7 +131,7 @@ export function CodeRemove(props: ToolProps) {
<ToolButton
{...props}
toggled={false}
onClick={() => editor.current?.chain().focus().unsetMark("code").run()}
onClick={() => editor.chain().focus().unsetMark("code").run()}
/>
);
}
@@ -142,7 +142,7 @@ export function Math(props: ToolProps) {
<ToolButton
{...props}
toggled={false}
onClick={() => editor.current?.chain().focus().insertMathInline().run()}
onClick={() => editor.chain().focus().insertMathInline().run()}
disabled={editor.isActive(CodeBlock.name)}
/>
);

View File

@@ -19,16 +19,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ToolProps } from "../types";
import { ToolButton } from "../components/tool-button";
import { useCallback, useRef, useState } from "react";
import { useRef, useState } from "react";
import { ResponsivePresenter } from "../../components/responsive";
import { LinkPopup } from "../popups/link-popup";
import { useToolbarLocation } from "../stores/toolbar-store";
import { MoreTools } from "../components/more-tools";
import { useRefValue } from "../../hooks/use-ref-value";
import { findMark, selectionToOffset } from "../../utils/prosemirror";
import { TextSelection } from "prosemirror-state";
import { Flex, Link } from "@theme-ui/components";
import { ImageNode } from "../../extensions/image";
import { Link as LinkNode } from "../../extensions/link";
import { getMarkAttributes } from "@tiptap/core";
export function LinkSettings(props: ToolProps) {
const { editor } = props;
@@ -53,49 +54,42 @@ export function LinkSettings(props: ToolProps) {
export function AddLink(props: ToolProps) {
const { editor } = props;
const isActive = props.editor.isActive("link");
const onDone = useCallback(
(link: LinkDefinition) => {
const { href, text, isImage } = link;
if (!href) return;
let commandChain = editor.current?.chain().focus();
if (!commandChain) return;
const isSelection = !editor.current?.state.selection.empty;
commandChain = commandChain
.extendMarkRange("link")
.toggleLink({ href, target: "_blank" });
if (!isImage) commandChain = commandChain.insertContent(text || href);
commandChain = commandChain.focus();
if (!isSelection && !isImage)
commandChain = commandChain.unsetMark("link").insertContent(" ");
commandChain.run();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const isActive = editor.isActive("link");
if (isActive) return <EditLink {...props} icon={"linkEdit"} />;
return (
<LinkTool
{...props}
onDone={onDone}
onDone={(attributes) => editor.commands.toggleLink(attributes)}
onClick={() => {
if (!editor.current) return;
const { state } = editor.current;
const { from, to } = state.selection;
if (!editor) return;
const selectedText = editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to
);
return { title: selectedText, href: "" };
}}
/>
);
}
const isImage = state.doc.nodeAt(from)?.type.name === ImageNode.name;
if (isImage) return { isImage };
export function AddInternalLink(props: ToolProps) {
const { editor } = props;
const isActive = editor.isActive(LinkNode.name);
const selectedText = state.doc.textBetween(from, to);
return { text: selectedText };
if (isActive) return null;
return (
<ToolButton
{...props}
onClick={async () => {
const link = await editor.storage.createInternalLink?.();
if (!link) return;
const selectedText = editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to
);
editor.commands.setLink({ ...link, title: selectedText || link.title });
}}
/>
);
@@ -106,66 +100,32 @@ export function EditLink(props: ToolProps) {
const selectedNode = useRefValue(
_selectedNode || selectionToOffset(editor.state)
);
const { node } = _selectedNode || {};
const link = node ? findMark(node, LinkNode.name) : null;
const attrs = link?.attrs || getMarkAttributes(editor.state, LinkNode.name);
const onDone = useCallback(
(link: LinkDefinition) => {
if (!selectedNode.current) return;
if (attrs && isInternalLink(attrs.href))
return (
<ToolButton
{...props}
onClick={async () => {
const link = await editor.storage.createInternalLink?.();
if (!link) return;
const { from, to } = editor.state.selection;
if (selectedNode.current)
editor.commands.setTextSelection(selectedNode.current);
editor.commands.setLink(link);
if (selectedNode.current)
editor.commands.setTextSelection({ from, to });
}}
/>
);
const { href, text, isImage } = link;
const { from, node, to } = selectedNode.current;
if (!href || !editor.current || !node) return;
const mark = findMark(node, "link");
if (!mark) return;
const selection = editor.current.state.selection;
let commandChain = editor.current.chain();
if (!isImage) {
commandChain = commandChain.command(({ tr }) => {
tr.removeMark(from, to, mark.type);
tr.insertText(
text || node.textContent,
tr.mapping.map(from),
tr.mapping.map(to)
);
tr.setSelection(
TextSelection.create(
tr.doc,
tr.mapping.map(from),
tr.mapping.map(to)
)
);
return true;
});
}
commandChain
.extendMarkRange("link")
.toggleLink({ href, target: "_blank" })
.command(({ tr }) => {
tr.setSelection(
TextSelection.create(
tr.doc,
tr.mapping.map(selection.from),
tr.mapping.map(selection.to)
)
);
return true;
})
.focus(undefined, { scrollIntoView: true })
.run();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
if (!editor.current?.isEditable) return null;
return (
<LinkTool
{...props}
isEditing
onDone={onDone}
onDone={(attributes) => editor.commands.setLink(attributes)}
onClick={() => {
if (!selectedNode.current) return;
@@ -177,9 +137,8 @@ export function EditLink(props: ToolProps) {
if (!mark) return;
return {
text: selectedText,
href: mark.attrs.href,
isImage: node.type.name === ImageNode.name
title: selectedText,
href: mark.attrs.href
};
}}
/>
@@ -188,18 +147,18 @@ export function EditLink(props: ToolProps) {
export function RemoveLink(props: ToolProps) {
const { editor, selectedNode } = props;
if (!editor.current?.isEditable) return null;
if (!editor.isEditable) return null;
return (
<ToolButton
{...props}
toggled={false}
onClick={() => {
if (selectedNode)
editor.current?.commands.setTextSelection({
editor.commands.setTextSelection({
from: selectedNode.from,
to: selectedNode.to
});
editor.current?.chain().focus().unsetLink().run();
editor.chain().focus().unsetLink().run();
}}
/>
);
@@ -221,7 +180,7 @@ export function OpenLink(props: ToolProps) {
href={href}
onClick={(e) => {
e.preventDefault();
editor.commands.openLink(href);
editor.storage.openLink?.(href);
}}
target="_blank"
variant="body"
@@ -244,7 +203,7 @@ export function OpenLink(props: ToolProps) {
{...props}
toggled={false}
onClick={() => {
editor.commands.openLink(href);
editor.storage.openLink?.(href);
}}
/>
</Flex>
@@ -266,16 +225,20 @@ export function CopyLink(props: ToolProps) {
{...props}
toggled={false}
onClick={() => {
editor.commands.copyToClipboard(href);
editor.storage.copyToClipboard?.(
href,
`<a href="${href}">${
selectedNode.current?.node?.textContent || link?.attrs.title
}</a>`
);
}}
/>
);
}
export type LinkDefinition = {
href?: string;
text?: string;
isImage?: boolean;
href: string;
title?: string;
};
type LinkToolProps = ToolProps & {
isEditing?: boolean;
@@ -288,6 +251,7 @@ function LinkTool(props: LinkToolProps) {
const [isOpen, setIsOpen] = useState(false);
const [linkDefinition, setLinkDefinition] = useState<LinkDefinition>();
const isImageActive = editor.isActive(ImageNode.name);
return (
<>
@@ -317,13 +281,14 @@ function LinkTool(props: LinkToolProps) {
items={[]}
onClose={() => {
setIsOpen(false);
editor.current?.commands.focus();
editor.commands.focus();
}}
focusOnRender={false}
>
<LinkPopup
link={linkDefinition}
isEditing={isEditing}
isImageActive={isImageActive}
onClose={() => setIsOpen(false)}
onDone={(link) => {
onDone(link);
@@ -334,3 +299,7 @@ function LinkTool(props: LinkToolProps) {
</>
);
}
function isInternalLink(href: string) {
return href.startsWith("nn://");
}

View File

@@ -90,10 +90,10 @@ function _ListTool<TListStyleTypes extends string>(
variant={"menuitem"}
sx={{ width: 80 }}
onClick={() => {
let chain = editor.current?.chain().focus();
if (!chain || !editor.current) return;
let chain = editor.chain().focus();
if (!chain || !editor) return;
if (!isListActive(editor.current)) {
if (!isListActive(editor)) {
if (type === "bulletList") chain = chain.toggleBulletList();
else chain = chain.toggleOrderedList();
}
@@ -120,7 +120,7 @@ export function NumberedList(props: ToolProps) {
const { editor } = props;
const onClick = useCallback(
() => editor.current?.chain().focus().toggleOrderedList().run(),
() => editor.chain().focus().toggleOrderedList().run(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
@@ -154,7 +154,7 @@ export function NumberedList(props: ToolProps) {
export function BulletList(props: ToolProps) {
const { editor } = props;
const onClick = useCallback(
() => editor.current?.chain().focus().toggleBulletList().run(),
() => editor.chain().focus().toggleBulletList().run(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
@@ -197,9 +197,7 @@ export function Indent(props: ToolProps) {
<ToolButton
{...toolProps}
toggled={false}
onClick={() =>
editor.current?.chain().focus().sinkListItem(listItemType).run()
}
onClick={() => editor.chain().focus().sinkListItem(listItemType).run()}
/>
);
}
@@ -215,9 +213,7 @@ export function Outdent(props: ToolProps) {
<ToolButton
{...toolProps}
toggled={false}
onClick={() =>
editor.current?.chain().focus().liftListItem(listItemType).run()
}
onClick={() => editor.chain().focus().liftListItem(listItemType).run()}
/>
);
}

View File

@@ -228,10 +228,10 @@ export function CellBackgroundColor(props: ToolProps) {
<ColorTool
{...props}
cacheKey="cellBackgroundColor"
activeColor={editor.current?.getAttributes("tableCell").backgroundColor}
activeColor={editor.getAttributes("tableCell").backgroundColor}
title={"Cell background color"}
onColorChange={(color) =>
editor.current?.chain().setCellAttribute("backgroundColor", color).run()
editor.chain().setCellAttribute("backgroundColor", color).run()
}
/>
);
@@ -244,10 +244,10 @@ export function CellTextColor(props: ToolProps) {
<ColorTool
{...props}
cacheKey="cellTextColor"
activeColor={editor.current?.getAttributes("tableCell").color}
activeColor={editor.getAttributes("tableCell").color}
title={"Cell text color"}
onColorChange={(color) =>
editor.current?.chain().focus().setCellAttribute("color", color).run()
editor.chain().focus().setCellAttribute("color", color).run()
}
/>
);
@@ -260,14 +260,10 @@ export function CellBorderColor(props: ToolProps) {
<ColorTool
{...props}
cacheKey="cellBorderColor"
activeColor={editor.current?.getAttributes("tableCell").borderColor}
activeColor={editor.getAttributes("tableCell").borderColor}
title={"Cell border color"}
onColorChange={(color) =>
editor.current
?.chain()
.focus()
.setCellAttribute("borderColor", color)
.run()
editor?.chain().focus().setCellAttribute("borderColor", color).run()
}
/>
);
@@ -295,20 +291,12 @@ export function CellBorderWidth(props: ToolProps) {
<Counter
title="cell border width"
onDecrease={() =>
editor.current?.commands.setCellAttribute(
"borderWidth",
decreaseBorderWidth()
)
editor.commands.setCellAttribute("borderWidth", decreaseBorderWidth())
}
onIncrease={() =>
editor.current?.commands.setCellAttribute(
"borderWidth",
increaseBorderWidth()
)
}
onReset={() =>
editor.current?.commands.setCellAttribute("borderWidth", 1)
editor.commands.setCellAttribute("borderWidth", increaseBorderWidth())
}
onReset={() => editor.commands.setCellAttribute("borderWidth", 1)}
value={borderWidth + "px"}
/>
);
@@ -316,12 +304,12 @@ export function CellBorderWidth(props: ToolProps) {
const insertColumnLeft = (editor: Editor): MenuButtonItem => ({
...toolToMenuButton(getToolDefinition("insertColumnLeft")),
onClick: () => editor.current?.chain().focus().addColumnBefore().run()
onClick: () => editor.chain().focus().addColumnBefore().run()
});
const insertColumnRight = (editor: Editor): MenuButtonItem => ({
...toolToMenuButton(getToolDefinition("insertColumnRight")),
onClick: () => editor.current?.chain().focus().addColumnAfter().run()
onClick: () => editor.chain().focus().addColumnAfter().run()
});
const moveColumnLeft = (editor: Editor): MenuButtonItem => ({
@@ -336,27 +324,27 @@ const moveColumnRight = (editor: Editor): MenuButtonItem => ({
const deleteColumn = (editor: Editor): MenuButtonItem => ({
...toolToMenuButton(getToolDefinition("deleteColumn")),
onClick: () => editor.current?.chain().focus().deleteColumn().run()
onClick: () => editor.chain().focus().deleteColumn().run()
});
const splitCells = (editor: Editor): MenuButtonItem => ({
...toolToMenuButton(getToolDefinition("splitCells")),
onClick: () => editor.current?.chain().focus().splitCell().run()
onClick: () => editor.chain().focus().splitCell().run()
});
const mergeCells = (editor: Editor): MenuButtonItem => ({
...toolToMenuButton(getToolDefinition("mergeCells")),
onClick: () => editor.current?.chain().focus().mergeCells().run()
onClick: () => editor.chain().focus().mergeCells().run()
});
const insertRowAbove = (editor: Editor): MenuButtonItem => ({
...toolToMenuButton(getToolDefinition("insertRowAbove")),
onClick: () => editor.current?.chain().focus().addRowBefore().run()
onClick: () => editor.chain().focus().addRowBefore().run()
});
const insertRowBelow = (editor: Editor): MenuButtonItem => ({
...toolToMenuButton(getToolDefinition("insertRowBelow")),
onClick: () => editor.current?.chain().focus().addRowAfter().run()
onClick: () => editor.chain().focus().addRowAfter().run()
});
const moveRowUp = (editor: Editor): MenuButtonItem => ({
@@ -370,12 +358,12 @@ const moveRowDown = (editor: Editor): MenuButtonItem => ({
const deleteRow = (editor: Editor): MenuButtonItem => ({
...toolToMenuButton(getToolDefinition("deleteRow")),
onClick: () => editor.current?.chain().focus().deleteRow().run()
onClick: () => editor.chain().focus().deleteRow().run()
});
const deleteTable = (editor: Editor): MenuButtonItem => ({
...toolToMenuButton(getToolDefinition("deleteTable")),
onClick: () => editor.current?.chain().focus().deleteTable().run()
onClick: () => editor.chain().focus().deleteTable().run()
});
const cellProperties = (editor: Editor): MenuButtonItem => ({

View File

@@ -38,11 +38,7 @@ function TextDirectionTool(props: TextDirectionToolProps) {
<ToolButton
{...toolProps}
onClick={() =>
editor.current
?.chain()
.focus()
.setTextDirection(directionRef.current)
.run()
editor?.chain().focus().setTextDirection(directionRef.current).run()
}
disabled={editor.isActive(CodeBlock.name)}
toggled={false}

View File

@@ -49,14 +49,14 @@ export function WebClipFullScreen(props: ToolProps) {
const offset = selectionToOffset(editor.state);
if (!offset) return;
const dom = editor.current?.view.nodeDOM(offset.from);
const dom = editor.view.nodeDOM(offset.from);
if (!dom || !(dom instanceof HTMLElement)) return;
const iframe = dom.querySelector("iframe");
if (!iframe) return;
iframe.requestFullscreen();
editor.current?.commands.updateAttributes("webclip", {
editor.commands.updateAttributes("webclip", {
fullscreen: true
});
}}
@@ -74,7 +74,7 @@ export function WebClipOpenExternal(props: ToolProps) {
const offset = selectionToOffset(editor.state);
if (!offset) return;
const dom = editor.current?.view.nodeDOM(offset.from);
const dom = editor.view.nodeDOM(offset.from);
if (!dom || !(dom instanceof HTMLElement)) return;
const iframe = dom.querySelector("iframe");
@@ -86,7 +86,7 @@ export function WebClipOpenExternal(props: ToolProps) {
{ type: "text/html" }
)
);
editor.current?.commands.openLink(url);
editor.storage.openLink?.(url);
}}
/>
);
@@ -101,7 +101,7 @@ export function WebClipOpenSource(props: ToolProps) {
onClick={async () => {
const node = findSelectedNode(editor, "webclip");
if (!node) return;
editor.current?.commands.openLink(node.attrs.src);
editor.storage.openLink?.(node.attrs.src);
}}
/>
);

View File

@@ -24,12 +24,6 @@ export type PermissionRequestEvent = CustomEvent<{ id: keyof UnionCommands }>;
export class Editor extends TiptapEditor {
private mutex: Mutex = new Mutex();
/**
* Use this to get the latest instance of the editor.
* This is required to reduce unnecessary rerenders of
* toolbar elements.
*/
current?: TiptapEditor;
/**
* Request permission before executing a command to make sure user
@@ -45,7 +39,7 @@ export class Editor extends TiptapEditor {
if (!window.dispatchEvent(event)) return undefined;
return this.current;
return this;
}
/**
@@ -54,8 +48,6 @@ export class Editor extends TiptapEditor {
* you are getting `RangeError: Applying a mismatched transaction` errors.
*/
threadsafe(callback: (editor: TiptapEditor) => void) {
return this.mutex.runExclusive(() =>
this.current ? callback(this.current) : void 0
);
return this.mutex.runExclusive(() => (this ? callback(this) : void 0));
}
}

View File

@@ -129,14 +129,12 @@ export function findMark(
export function selectionToOffset(
state: EditorState
): NodeWithOffset | undefined {
const { from, $from } = state.selection;
const { from, $from, to, $to } = state.selection;
const node = state.doc.nodeAt(from);
if (!node) return;
return {
node,
node: node || undefined,
from: from - $from.textOffset,
to: from - $from.textOffset + node.nodeSize
to: node ? from - $from.textOffset + node.nodeSize : to - $to.textOffset
};
}

View File

@@ -87,6 +87,22 @@
cursor: pointer;
}
.ProseMirror a[href^="nn://"]::before {
display: inline-block;
cursor: pointer;
content: "";
background-size: 14px;
width: 14px;
height: 14px;
background-color: var(--accent);
mask: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9ImN1cnJlbnRDb2xvciIgZD0iTTMgMWMtMS4xMSAwLTIgLjg5LTIgMnYxMWMwIDEuMTEuODkgMiAyIDJoMTFjMS4xMSAwIDItLjg5IDItMnYtM2gtMnYzSDNWM2gxMXYyaDJWM2MwLTEuMTEtLjg5LTItMi0yTTkgN2MtMS4xMSAwLTIgLjg5LTIgMnYzaDJWOWgxMXYxMUg5di0ySDd2MmMwIDEuMTEuODkgMiAyIDJoMTFjMS4xMSAwIDItLjg5IDItMlY5YzAtMS4xMS0uODktMi0yLTJ6Ii8+PC9zdmc+)
no-repeat 50% 50%;
mask-size: cover;
margin-right: 2px;
}
.ProseMirror a:hover {
filter: brightness(70%);
}