chore: Move editor into codebase (#2930)

This commit is contained in:
Tom Moor
2022-01-19 18:43:15 -08:00
committed by GitHub
parent 266f8c96c4
commit 062016b164
216 changed files with 12417 additions and 382 deletions

View File

@@ -0,0 +1,5 @@
https://prosemirror.net/docs/ref/#commands
Commands are building block functions that encapsulate an editing action. A command function takes an editor state, optionally a dispatch function that it can use to dispatch a transaction and optionally an EditorView instance. It should return a boolean that indicates whether it could perform any action.
Additional commands that are not included as part of prosemirror-commands, but are often reused can be found in this folder.

View File

@@ -0,0 +1,26 @@
import { NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
export default function backspaceToParagraph(type: NodeType) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const { $from, from, to, empty } = state.selection;
// if the selection has anything in it then use standard delete behavior
if (!empty) return null;
// check we're in a matching node
if ($from.parent.type !== type) return null;
// check if we're at the beginning of the heading
const $pos = state.doc.resolve(from - 1);
if ($pos.parent === $from.parent) return null;
// okay, replace it with a paragraph
dispatch(
state.tr
.setBlockType(from, to, type.schema.nodes.paragraph)
.scrollIntoView()
);
return true;
};
}

View File

@@ -0,0 +1,85 @@
import { Node } from "prosemirror-model";
import { EditorView } from "prosemirror-view";
import { ToastType } from "../types";
function findPlaceholderLink(doc: Node, href: string) {
let result: { pos: number; node: Node } | undefined;
function findLinks(node: Node, pos = 0) {
// get text nodes
if (node.type.name === "text") {
// get marks for text nodes
node.marks.forEach((mark) => {
// any of the marks links?
if (mark.type.name === "link") {
// any of the links to other docs?
if (mark.attrs.href === href) {
result = { node, pos };
}
}
});
}
if (!node.content.size) {
return;
}
node.descendants(findLinks);
}
findLinks(doc);
return result;
}
const createAndInsertLink = async function (
view: EditorView,
title: string,
href: string,
options: {
dictionary: any;
onCreateLink: (title: string) => Promise<string>;
onShowToast?: (message: string, code: string) => void;
}
) {
const { dispatch, state } = view;
const { onCreateLink, onShowToast } = options;
try {
const url = await onCreateLink(title);
const result = findPlaceholderLink(view.state.doc, href);
if (!result) return;
dispatch(
view.state.tr
.removeMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link
)
.addMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link.create({ href: url })
)
);
} catch (err) {
const result = findPlaceholderLink(view.state.doc, href);
if (!result) return;
dispatch(
view.state.tr.removeMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link
)
);
// let the user know
if (onShowToast) {
onShowToast(options.dictionary.createLinkError, ToastType.Error);
}
}
};
export default createAndInsertLink;

View File

@@ -0,0 +1,143 @@
import { NodeSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import uploadPlaceholderPlugin, {
findPlaceholder,
} from "../lib/uploadPlaceholder";
import { ToastType } from "../types";
let uploadId = 0;
export type Options = {
dictionary: any;
replaceExisting?: boolean;
uploadImage: (file: File) => Promise<string>;
onImageUploadStart?: () => void;
onImageUploadStop?: () => void;
onShowToast?: (message: string, code: string) => void;
};
const insertFiles = function (
view: EditorView,
event: Event | React.ChangeEvent<HTMLInputElement>,
pos: number,
files: File[],
options: Options
): void {
// filter to only include image files
const images = files.filter((file) => /image/i.test(file.type));
if (images.length === 0) return;
const {
dictionary,
uploadImage,
onImageUploadStart,
onImageUploadStop,
onShowToast,
} = options;
if (!uploadImage) {
console.warn(
"uploadImage callback must be defined to handle image uploads."
);
return;
}
// okay, we have some dropped images and a handler lets stop this
// event going any further up the stack
event.preventDefault();
// let the user know we're starting to process the images
if (onImageUploadStart) onImageUploadStart();
const { schema } = view.state;
// we'll use this to track of how many images have succeeded or failed
let complete = 0;
// the user might have dropped multiple images at once, we need to loop
for (const file of images) {
const id = `upload-${uploadId++}`;
const { tr } = view.state;
// insert a placeholder at this position, or mark an existing image as being
// replaced
tr.setMeta(uploadPlaceholderPlugin, {
add: {
id,
file,
pos,
replaceExisting: options.replaceExisting,
},
});
view.dispatch(tr);
// start uploading the image file to the server. Using "then" syntax
// to allow all placeholders to be entered at once with the uploads
// happening in the background in parallel.
uploadImage(file)
.then((src) => {
// otherwise, insert it at the placeholder's position, and remove
// the placeholder itself
const newImg = new Image();
newImg.onload = () => {
const result = findPlaceholder(view.state, id);
// if the content around the placeholder has been deleted
// then forget about inserting this image
if (result === null) {
return;
}
const [from, to] = result;
view.dispatch(
view.state.tr
.replaceWith(from, to || from, schema.nodes.image.create({ src }))
.setMeta(uploadPlaceholderPlugin, { remove: { id } })
);
// If the users selection is still at the image then make sure to select
// the entire node once done. Otherwise, if the selection has moved
// elsewhere then we don't want to modify it
if (view.state.selection.from === from) {
view.dispatch(
view.state.tr.setSelection(
new NodeSelection(view.state.doc.resolve(from))
)
);
}
};
newImg.onerror = (error) => {
throw error;
};
newImg.src = src;
})
.catch((error) => {
console.error(error);
// cleanup the placeholder if there is a failure
const transaction = view.state.tr.setMeta(uploadPlaceholderPlugin, {
remove: { id },
});
view.dispatch(transaction);
// let the user know
if (onShowToast) {
onShowToast(dictionary.imageUploadError, ToastType.Error);
}
})
.finally(() => {
complete++;
// once everything is done, let the user know
if (complete === images.length && onImageUploadStop) {
onImageUploadStop();
}
});
}
};
export default insertFiles;

View File

@@ -0,0 +1,111 @@
/*
Copyright 2020 Atlassian Pty Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// This file is based on the implementation found here:
// https://bitbucket.org/atlassian/design-system-mirror/src/master/editor/editor-core/src/plugins/text-formatting/commands/text-formatting.ts
import {
Selection,
EditorState,
Transaction,
TextSelection,
} from "prosemirror-state";
import isMarkActive from "../queries/isMarkActive";
function hasCode(state: EditorState, pos: number) {
const { code_inline } = state.schema.marks;
const node = pos >= 0 && state.doc.nodeAt(pos);
return node
? !!node.marks.filter((mark) => mark.type === code_inline).length
: false;
}
export default function moveLeft() {
return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => {
const { code_inline } = state.schema.marks;
const { empty, $cursor } = state.selection as TextSelection;
if (!empty || !$cursor) {
return false;
}
const { storedMarks } = state.tr;
if (code_inline) {
const insideCode = code_inline && isMarkActive(code_inline)(state);
const currentPosHasCode = hasCode(state, $cursor.pos);
const nextPosHasCode = hasCode(state, $cursor.pos - 1);
const nextNextPosHasCode = hasCode(state, $cursor.pos - 2);
const exitingCode =
currentPosHasCode && !nextPosHasCode && Array.isArray(storedMarks);
const atLeftEdge =
nextPosHasCode &&
!nextNextPosHasCode &&
(storedMarks === null ||
(Array.isArray(storedMarks) && !!storedMarks.length));
const atRightEdge =
((exitingCode && Array.isArray(storedMarks) && !storedMarks.length) ||
(!exitingCode && storedMarks === null)) &&
!nextPosHasCode &&
nextNextPosHasCode;
const enteringCode =
!currentPosHasCode &&
nextPosHasCode &&
Array.isArray(storedMarks) &&
!storedMarks.length;
// at the right edge: remove code mark and move the cursor to the left
if (!insideCode && atRightEdge) {
const tr = state.tr.setSelection(
Selection.near(state.doc.resolve($cursor.pos - 1))
);
dispatch(tr.removeStoredMark(code_inline));
return true;
}
// entering code mark (from right edge): don't move the cursor, just add the mark
if (!insideCode && enteringCode) {
dispatch(state.tr.addStoredMark(code_inline.create()));
return true;
}
// at the left edge: add code mark and move the cursor to the left
if (insideCode && atLeftEdge) {
const tr = state.tr.setSelection(
Selection.near(state.doc.resolve($cursor.pos - 1))
);
dispatch(tr.addStoredMark(code_inline.create()));
return true;
}
// exiting code mark (or at the beginning of the line): don't move the cursor, just remove the mark
const isFirstChild = $cursor.index($cursor.depth - 1) === 0;
if (
insideCode &&
(exitingCode || (!$cursor.nodeBefore && isFirstChild))
) {
dispatch(state.tr.removeStoredMark(code_inline));
return true;
}
}
return false;
};
}

View File

@@ -0,0 +1,71 @@
/*
Copyright 2020 Atlassian Pty Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// This file is based on the implementation found here:
// https://bitbucket.org/atlassian/design-system-mirror/src/master/editor/editor-core/src/plugins/text-formatting/commands/text-formatting.ts
import { EditorState, Transaction, TextSelection } from "prosemirror-state";
import isMarkActive from "../queries/isMarkActive";
export default function moveRight() {
return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => {
const { code_inline } = state.schema.marks;
const { empty, $cursor } = state.selection as TextSelection;
if (!empty || !$cursor) {
return false;
}
const { storedMarks } = state.tr;
if (code_inline) {
const insideCode = isMarkActive(code_inline)(state);
const currentPosHasCode = state.doc.rangeHasMark(
$cursor.pos,
$cursor.pos,
code_inline
);
const nextPosHasCode = state.doc.rangeHasMark(
$cursor.pos,
$cursor.pos + 1,
code_inline
);
const exitingCode =
!currentPosHasCode &&
!nextPosHasCode &&
(!storedMarks || !!storedMarks.length);
const enteringCode =
!currentPosHasCode &&
nextPosHasCode &&
(!storedMarks || !storedMarks.length);
// entering code mark (from the left edge): don't move the cursor, just add the mark
if (!insideCode && enteringCode) {
dispatch(state.tr.addStoredMark(code_inline.create()));
return true;
}
// exiting code mark: don't move the cursor, just remove the mark
if (insideCode && exitingCode) {
dispatch(state.tr.removeStoredMark(code_inline));
return true;
}
}
return false;
};
}

View File

@@ -0,0 +1,54 @@
import { NodeType } from "prosemirror-model";
import { EditorState, TextSelection, Transaction } from "prosemirror-state";
import { findBlockNodes } from "prosemirror-utils";
import findCollapsedNodes from "../queries/findCollapsedNodes";
export default function splitHeading(type: NodeType) {
return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => {
const { $from, from, $to, to } = state.selection;
// check we're in a matching heading node
if ($from.parent.type !== type) return false;
// check that the caret is at the end of the content, if it isn't then
// standard node splitting behaviour applies
const endPos = $to.after() - 1;
if (endPos !== to) return false;
// If the node isn't collapsed standard behavior applies
if (!$from.parent.attrs.collapsed) return false;
// Find the next visible block after this one. It takes into account nested
// collapsed headings and reaching the end of the document
const allBlocks = findBlockNodes(state.doc);
const collapsedBlocks = findCollapsedNodes(state.doc);
const visibleBlocks = allBlocks.filter(
(a) => !collapsedBlocks.find((b) => b.pos === a.pos)
);
const nextVisibleBlock = visibleBlocks.find((a) => a.pos > from);
const pos = nextVisibleBlock
? nextVisibleBlock.pos
: state.doc.content.size;
// Insert our new heading directly before the next visible block
const transaction = state.tr.insert(
pos,
type.create({ ...$from.parent.attrs, collapsed: false })
);
// Move the selection into the new heading node and make sure it's on screen
dispatch(
transaction
.setSelection(
TextSelection.near(
transaction.doc.resolve(
Math.min(pos + 1, transaction.doc.content.size)
)
)
)
.scrollIntoView()
);
return true;
};
}

View File

@@ -0,0 +1,20 @@
import { setBlockType } from "prosemirror-commands";
import { NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import isNodeActive from "../queries/isNodeActive";
export default function toggleBlockType(
type: NodeType,
toggleType: NodeType,
attrs = {}
) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const isActive = isNodeActive(type, attrs)(state);
if (isActive) {
return setBlockType(toggleType)(state, dispatch);
}
return setBlockType(type, attrs)(state, dispatch);
};
}

View File

@@ -0,0 +1,43 @@
import { NodeType } from "prosemirror-model";
import { wrapInList, liftListItem } from "prosemirror-schema-list";
import { EditorState, Transaction } from "prosemirror-state";
import { findParentNode } from "prosemirror-utils";
import isList from "../queries/isList";
export default function toggleList(listType: NodeType, itemType: NodeType) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const { schema, selection } = state;
const { $from, $to } = selection;
const range = $from.blockRange($to);
if (!range) {
return false;
}
const parentList = findParentNode((node) => isList(node, schema))(
selection
);
if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) {
if (parentList.node.type === listType) {
return liftListItem(itemType)(state, dispatch);
}
if (
isList(parentList.node, schema) &&
listType.validContent(parentList.node.content)
) {
const { tr } = state;
tr.setNodeMarkup(parentList.pos, listType);
if (dispatch) {
dispatch(tr);
}
return false;
}
}
return wrapInList(listType)(state, dispatch);
};
}

View File

@@ -0,0 +1,19 @@
import { wrapIn, lift } from "prosemirror-commands";
import { NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import isNodeActive from "../queries/isNodeActive";
export default function toggleWrap(
type: NodeType,
attrs?: Record<string, any>
) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const isActive = isNodeActive(type)(state);
if (isActive) {
return lift(state, dispatch);
}
return wrapIn(type, attrs)(state, dispatch);
};
}