mirror of
https://github.com/outline/outline.git
synced 2026-01-06 11:09:55 -06:00
950 lines
27 KiB
TypeScript
950 lines
27 KiB
TypeScript
/* global File Promise */
|
|
import { PluginSimple } from "markdown-it";
|
|
import { observable } from "mobx";
|
|
import { Observer } from "mobx-react";
|
|
import { darken, transparentize } from "polished";
|
|
import { baseKeymap } from "prosemirror-commands";
|
|
import { dropCursor } from "prosemirror-dropcursor";
|
|
import { gapCursor } from "prosemirror-gapcursor";
|
|
import { inputRules, InputRule } from "prosemirror-inputrules";
|
|
import { keymap } from "prosemirror-keymap";
|
|
import { MarkdownParser } from "prosemirror-markdown";
|
|
import {
|
|
Schema,
|
|
NodeSpec,
|
|
MarkSpec,
|
|
Node as ProsemirrorNode,
|
|
} from "prosemirror-model";
|
|
import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
|
|
import {
|
|
AddMarkStep,
|
|
RemoveMarkStep,
|
|
ReplaceAroundStep,
|
|
ReplaceStep,
|
|
} from "prosemirror-transform";
|
|
import { Decoration, EditorView, NodeViewConstructor } from "prosemirror-view";
|
|
import * as React from "react";
|
|
import styled, { css, DefaultTheme, ThemeProps } from "styled-components";
|
|
import insertFiles from "@shared/editor/commands/insertFiles";
|
|
import Styles from "@shared/editor/components/Styles";
|
|
import { EmbedDescriptor } from "@shared/editor/embeds";
|
|
import Extension, {
|
|
CommandFactory,
|
|
WidgetProps,
|
|
} from "@shared/editor/lib/Extension";
|
|
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
|
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
|
import textBetween from "@shared/editor/lib/textBetween";
|
|
import Mark from "@shared/editor/marks/Mark";
|
|
import { basicExtensions as extensions } from "@shared/editor/nodes";
|
|
import Node from "@shared/editor/nodes/Node";
|
|
import ReactNode from "@shared/editor/nodes/ReactNode";
|
|
import { ComponentProps } from "@shared/editor/types";
|
|
import { ProsemirrorData, UserPreferences } from "@shared/types";
|
|
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
|
import EventEmitter from "@shared/utils/events";
|
|
import Document from "~/models/Document";
|
|
import Flex from "~/components/Flex";
|
|
import { PortalContext } from "~/components/Portal";
|
|
import { Dictionary } from "~/hooks/useDictionary";
|
|
import { Properties } from "~/types";
|
|
import Logger from "~/utils/Logger";
|
|
import ComponentView from "./components/ComponentView";
|
|
import EditorContext from "./components/EditorContext";
|
|
import { NodeViewRenderer } from "./components/NodeViewRenderer";
|
|
|
|
import WithTheme from "./components/WithTheme";
|
|
import isNull from "lodash/isNull";
|
|
import { isArray, map } from "lodash";
|
|
import {
|
|
LightboxImage,
|
|
LightboxImageFactory,
|
|
} from "@shared/editor/lib/Lightbox";
|
|
import Lightbox from "~/components/Lightbox";
|
|
import { anchorPlugin } from "@shared/editor/plugins/AnchorPlugin";
|
|
|
|
export type Props = {
|
|
/** An optional identifier for the editor context. It is used to persist local settings */
|
|
id?: string;
|
|
/** The user id of the current user */
|
|
userId?: string;
|
|
/** The editor content, should only be changed if you wish to reset the content */
|
|
value?: string | ProsemirrorData;
|
|
/** The initial editor content as a markdown string or JSON object */
|
|
defaultValue: string | object;
|
|
/** Placeholder displayed when the editor is empty */
|
|
placeholder: string;
|
|
/** Extensions to load into the editor */
|
|
extensions?: (typeof Node | typeof Mark | typeof Extension | Extension)[];
|
|
/** If the editor should be focused on mount */
|
|
autoFocus?: boolean;
|
|
/** The focused comment, if any */
|
|
focusedCommentId?: string;
|
|
/** If the editor should not allow editing */
|
|
readOnly?: boolean;
|
|
/** If the editor should still allow editing checkboxes when it is readOnly */
|
|
canUpdate?: boolean;
|
|
/** If the editor should still allow commenting when it is readOnly */
|
|
canComment?: boolean;
|
|
/** A dictionary of translated strings used in the editor */
|
|
dictionary: Dictionary;
|
|
/** The reading direction of the text content, if known */
|
|
dir?: "rtl" | "ltr";
|
|
/** If the editor should vertically grow to fill available space */
|
|
grow?: boolean;
|
|
/** If the editor should display template options such as inserting placeholders */
|
|
template?: boolean;
|
|
/** An enforced maximum content length */
|
|
maxLength?: number;
|
|
/** Heading id to scroll to when the editor has loaded */
|
|
scrollTo?: string;
|
|
/** Callback for handling uploaded images, should return the url of uploaded file */
|
|
uploadFile?: (file: File) => Promise<string>;
|
|
/** Callback when prosemirror nodes are initialized on document mount. */
|
|
onInit?: () => void;
|
|
/** Callback when prosemirror nodes are destroyed on document unmount. */
|
|
onDestroy?: () => void;
|
|
/** Callback when editor is blurred, as native input */
|
|
onBlur?: () => void;
|
|
/** Callback when editor is focused, as native input */
|
|
onFocus?: () => void;
|
|
/** Callback when user uses save key combo */
|
|
onSave?: (options: { done: boolean }) => void;
|
|
/** Callback when user uses cancel key combo */
|
|
onCancel?: () => void;
|
|
/** Callback when user changes editor content */
|
|
onChange?: (value: () => any) => void;
|
|
/** Callback when a comment mark is clicked */
|
|
onClickCommentMark?: (commentId: string) => void;
|
|
/** Callback when a comment mark is created */
|
|
onCreateCommentMark?: (commentId: string, userId: string) => void;
|
|
/** Callback when a comment mark is removed */
|
|
onDeleteCommentMark?: (commentId: string) => void;
|
|
/** Callback when a file upload begins */
|
|
onFileUploadStart?: () => void;
|
|
/** Callback when a file upload ends */
|
|
onFileUploadStop?: () => void;
|
|
/** Callback when a link is created, should return url to created document */
|
|
onCreateLink?: (params: Properties<Document>) => Promise<string>;
|
|
/** Callback when user clicks on any link in the document */
|
|
onClickLink: (
|
|
href: string,
|
|
event?: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
|
) => void;
|
|
/** Callback when user presses any key with document focused */
|
|
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
|
|
/** Collection of embed types to render in the document */
|
|
embeds: EmbedDescriptor[];
|
|
/** Display preferences for the logged in user, if any. */
|
|
userPreferences?: UserPreferences | null;
|
|
/** Whether embeds should be rendered without an iframe */
|
|
embedsDisabled?: boolean;
|
|
className?: string;
|
|
/** Optional style overrides for the container*/
|
|
style?: React.CSSProperties;
|
|
/** Optional style overrides for the contenteeditable */
|
|
editorStyle?: React.CSSProperties;
|
|
lang?: string;
|
|
};
|
|
|
|
type State = {
|
|
/** If the document text has been detected as using RTL script */
|
|
isRTL: boolean;
|
|
/** If the editor is currently focused */
|
|
isEditorFocused: boolean;
|
|
/** Image that's being currently viewed in Lightbox */
|
|
activeLightboxImage: LightboxImage | null;
|
|
};
|
|
|
|
/**
|
|
* The shared editor at the root of all rich editable text in Outline. Do not
|
|
* use this component directly, it should by lazy loaded. Use
|
|
* ~/components/Editor instead.
|
|
*/
|
|
export class Editor extends React.PureComponent<
|
|
Props & ThemeProps<DefaultTheme>,
|
|
State
|
|
> {
|
|
static defaultProps = {
|
|
defaultValue: "",
|
|
dir: "auto",
|
|
placeholder: "Write something nice…",
|
|
onFileUploadStart: () => {
|
|
// no default behavior
|
|
},
|
|
onFileUploadStop: () => {
|
|
// no default behavior
|
|
},
|
|
embeds: [],
|
|
extensions,
|
|
};
|
|
|
|
state: State = {
|
|
isRTL: false,
|
|
isEditorFocused: false,
|
|
activeLightboxImage: null,
|
|
};
|
|
|
|
isInitialized = false;
|
|
isBlurred = true;
|
|
extensions: ExtensionManager;
|
|
elementRef = React.createRef<HTMLDivElement>();
|
|
wrapperRef = React.createRef<HTMLDivElement>();
|
|
view: EditorView;
|
|
schema: Schema;
|
|
serializer: MarkdownSerializer;
|
|
parser: MarkdownParser;
|
|
pasteParser: MarkdownParser;
|
|
plugins: Plugin[];
|
|
keymaps: Plugin[];
|
|
inputRules: InputRule[];
|
|
nodeViews: {
|
|
[name: string]: NodeViewConstructor;
|
|
};
|
|
|
|
widgets: { [name: string]: (props: WidgetProps) => React.ReactElement };
|
|
renderers = observable.set<NodeViewRenderer<ComponentProps>>();
|
|
nodes: { [name: string]: NodeSpec };
|
|
marks: { [name: string]: MarkSpec };
|
|
commands: Record<string, CommandFactory>;
|
|
rulePlugins: PluginSimple[];
|
|
events = new EventEmitter();
|
|
mutationObserver?: MutationObserver;
|
|
|
|
/**
|
|
* We use componentDidMount instead of constructor as the init method requires
|
|
* that the dom is already mounted.
|
|
*/
|
|
public componentDidMount() {
|
|
this.init();
|
|
window.addEventListener("theme-changed", this.dispatchThemeChanged);
|
|
|
|
if (this.props.scrollTo) {
|
|
void this.scrollToAnchor(this.props.scrollTo);
|
|
}
|
|
|
|
this.calculateDir();
|
|
|
|
if (this.props.readOnly) {
|
|
return;
|
|
}
|
|
|
|
if (this.props.autoFocus) {
|
|
this.focusAtEnd();
|
|
}
|
|
}
|
|
|
|
public componentDidUpdate(prevProps: Props) {
|
|
// Allow changes to the 'value' prop to update the editor from outside
|
|
if (this.props.value && prevProps.value !== this.props.value) {
|
|
const newState = this.createState(this.props.value);
|
|
this.view.updateState(newState);
|
|
}
|
|
|
|
// pass readOnly changes through to underlying editor instance
|
|
if (prevProps.readOnly !== this.props.readOnly) {
|
|
this.view.update({
|
|
...this.view.props,
|
|
editable: () => !this.props.readOnly,
|
|
});
|
|
|
|
// NodeView will not automatically render when editable changes so we must trigger an update
|
|
// manually, see: https://discuss.prosemirror.net/t/re-render-custom-nodeview-when-view-editable-changes/6441
|
|
Array.from(this.renderers).forEach((view) =>
|
|
view.setProp("isEditable", !this.props.readOnly)
|
|
);
|
|
}
|
|
|
|
if (this.props.scrollTo && this.props.scrollTo !== prevProps.scrollTo) {
|
|
void this.scrollToAnchor(this.props.scrollTo);
|
|
}
|
|
|
|
// Focus at the end of the document if switching from readOnly and autoFocus
|
|
// is set to true
|
|
if (prevProps.readOnly && !this.props.readOnly && this.props.autoFocus) {
|
|
this.focusAtEnd();
|
|
}
|
|
|
|
if (prevProps.dir !== this.props.dir) {
|
|
this.calculateDir();
|
|
}
|
|
|
|
if (!this.isBlurred && !this.state.isEditorFocused) {
|
|
this.isBlurred = true;
|
|
this.props.onBlur?.();
|
|
}
|
|
|
|
if (this.isBlurred && this.state.isEditorFocused) {
|
|
this.isBlurred = false;
|
|
this.props.onFocus?.();
|
|
}
|
|
}
|
|
|
|
public componentWillUnmount(): void {
|
|
window.removeEventListener("theme-changed", this.dispatchThemeChanged);
|
|
this.view?.destroy();
|
|
this.mutationObserver?.disconnect();
|
|
this.handleEditorDestroy();
|
|
}
|
|
|
|
private init() {
|
|
this.extensions = this.createExtensions();
|
|
this.nodes = this.createNodes();
|
|
this.marks = this.createMarks();
|
|
this.schema = this.createSchema();
|
|
this.widgets = this.createWidgets();
|
|
this.plugins = this.createPlugins();
|
|
this.rulePlugins = this.createRulePlugins();
|
|
this.keymaps = this.createKeymaps();
|
|
this.serializer = this.createSerializer();
|
|
this.parser = this.createParser();
|
|
this.pasteParser = this.createPasteParser();
|
|
this.inputRules = this.createInputRules();
|
|
this.nodeViews = this.createNodeViews();
|
|
this.view = this.createView();
|
|
this.commands = this.createCommands();
|
|
}
|
|
|
|
private createExtensions() {
|
|
return new ExtensionManager(this.props.extensions, this);
|
|
}
|
|
|
|
private createPlugins() {
|
|
return this.extensions.plugins;
|
|
}
|
|
|
|
private createRulePlugins() {
|
|
return this.extensions.rulePlugins;
|
|
}
|
|
|
|
private createKeymaps() {
|
|
return this.extensions.keymaps({
|
|
schema: this.schema,
|
|
});
|
|
}
|
|
|
|
private createInputRules() {
|
|
return this.extensions.inputRules({
|
|
schema: this.schema,
|
|
});
|
|
}
|
|
|
|
private createNodeViews() {
|
|
return this.extensions.extensions
|
|
.filter((extension: ReactNode) => extension.component)
|
|
.reduce(
|
|
(nodeViews, extension: ReactNode) => ({
|
|
...nodeViews,
|
|
[extension.name]: (
|
|
node: ProsemirrorNode,
|
|
view: EditorView,
|
|
getPos: () => number,
|
|
decorations: Decoration[]
|
|
) =>
|
|
new ComponentView(extension.component, {
|
|
editor: this,
|
|
extension,
|
|
node,
|
|
view,
|
|
getPos,
|
|
decorations,
|
|
}),
|
|
}),
|
|
{}
|
|
);
|
|
}
|
|
|
|
private createCommands() {
|
|
return this.extensions.commands({
|
|
schema: this.schema,
|
|
view: this.view,
|
|
});
|
|
}
|
|
|
|
private createWidgets() {
|
|
return this.extensions.widgets;
|
|
}
|
|
|
|
private createNodes() {
|
|
return this.extensions.nodes;
|
|
}
|
|
|
|
private createMarks() {
|
|
return this.extensions.marks;
|
|
}
|
|
|
|
private createSchema() {
|
|
return new Schema({
|
|
nodes: this.nodes,
|
|
marks: this.marks,
|
|
});
|
|
}
|
|
|
|
private createSerializer() {
|
|
return this.extensions.serializer();
|
|
}
|
|
|
|
private createParser() {
|
|
return this.extensions.parser({
|
|
schema: this.schema,
|
|
plugins: this.rulePlugins,
|
|
});
|
|
}
|
|
|
|
private createPasteParser() {
|
|
return this.extensions.parser({
|
|
schema: this.schema,
|
|
rules: { linkify: true },
|
|
plugins: this.rulePlugins,
|
|
});
|
|
}
|
|
|
|
private createState(value?: string | object) {
|
|
const doc = this.createDocument(value || this.props.defaultValue);
|
|
|
|
return EditorState.create({
|
|
schema: this.schema,
|
|
doc,
|
|
plugins: [
|
|
...this.keymaps,
|
|
...this.plugins,
|
|
anchorPlugin(),
|
|
dropCursor({
|
|
color: this.props.theme.cursor,
|
|
}),
|
|
gapCursor(),
|
|
inputRules({
|
|
rules: this.inputRules,
|
|
}),
|
|
keymap(baseKeymap),
|
|
],
|
|
});
|
|
}
|
|
|
|
private createDocument(content: string | object) {
|
|
// Looks like Markdown
|
|
if (typeof content === "string") {
|
|
return this.parser.parse(content) || undefined;
|
|
}
|
|
|
|
return ProsemirrorNode.fromJSON(this.schema, content);
|
|
}
|
|
|
|
private createView() {
|
|
if (!this.elementRef.current) {
|
|
throw new Error("createView called before ref available");
|
|
}
|
|
|
|
const isEditingCheckbox = (tr: Transaction) =>
|
|
tr.steps.some(
|
|
(step) =>
|
|
(step instanceof ReplaceAroundStep || step instanceof ReplaceStep) &&
|
|
step.slice.content?.firstChild?.type.name ===
|
|
this.schema.nodes.checkbox_item.name
|
|
);
|
|
|
|
const isEditingComment = (tr: Transaction) =>
|
|
tr.steps.some(
|
|
(step) =>
|
|
(step instanceof AddMarkStep || step instanceof RemoveMarkStep) &&
|
|
step.mark.type.name === this.schema.marks.comment.name
|
|
);
|
|
|
|
const self = this; // oxlint-disable-line
|
|
const view = new EditorView(this.elementRef.current, {
|
|
handleDOMEvents: {
|
|
blur: this.handleEditorBlur,
|
|
focus: this.handleEditorFocus,
|
|
},
|
|
attributes: {
|
|
translate: this.props.readOnly ? "yes" : "no",
|
|
},
|
|
state: this.createState(this.props.value),
|
|
editable: () => !this.props.readOnly,
|
|
nodeViews: this.nodeViews,
|
|
dispatchTransaction(this: EditorView, transaction) {
|
|
if (this.isDestroyed) {
|
|
return;
|
|
}
|
|
|
|
// callback is bound to have the view instance as its this binding
|
|
const { state, transactions } =
|
|
this.state.applyTransaction(transaction);
|
|
|
|
this.updateState(state);
|
|
|
|
// If any of the transactions being dispatched resulted in the doc
|
|
// changing then call our own change handler to let the outside world
|
|
// know
|
|
if (
|
|
transactions.some((tr) => tr.docChanged) &&
|
|
(!self.props.readOnly ||
|
|
(self.props.canUpdate && transactions.some(isEditingCheckbox)) ||
|
|
(self.props.canComment && transactions.some(isEditingComment)))
|
|
) {
|
|
self.handleChange();
|
|
}
|
|
|
|
self.handleEditorInit();
|
|
|
|
self.calculateDir();
|
|
|
|
// Because Prosemirror and React are not linked we must tell React that
|
|
// a render is needed whenever the Prosemirror state changes.
|
|
self.forceUpdate();
|
|
},
|
|
});
|
|
|
|
// Tell third-party libraries and screen-readers that this is an input
|
|
view.dom.setAttribute("role", "textbox");
|
|
view.dom.setAttribute("aria-label", "Editor content");
|
|
|
|
return view;
|
|
}
|
|
|
|
public async scrollToAnchor(hash: string) {
|
|
if (!hash) {
|
|
return;
|
|
}
|
|
|
|
function isVisible(element: HTMLElement | null) {
|
|
for (let e = element; e; e = e.parentElement) {
|
|
const s = getComputedStyle(e);
|
|
if (s.display === "none" || s.opacity === "0") {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
this.mutationObserver?.disconnect();
|
|
this.mutationObserver = observe(
|
|
hash,
|
|
(element) => {
|
|
if (isVisible(element)) {
|
|
element.scrollIntoView();
|
|
}
|
|
},
|
|
this.elementRef.current || undefined
|
|
);
|
|
} catch (_err) {
|
|
// querySelector will throw an error if the hash begins with a number
|
|
// or contains a period. This is protected against now by safeSlugify
|
|
// however previous links may be in the wild.
|
|
Logger.debug("editor", `Attempted to scroll to invalid hash: ${hash}`);
|
|
}
|
|
}
|
|
|
|
public value = (asString = true, trim?: boolean) => {
|
|
if (asString) {
|
|
const content = this.serializer.serialize(this.view.state.doc);
|
|
return trim ? content.trim() : content;
|
|
}
|
|
|
|
return (
|
|
trim ? ProsemirrorHelper.trim(this.view.state.doc) : this.view.state.doc
|
|
).toJSON();
|
|
};
|
|
|
|
private calculateDir = () => {
|
|
if (!this.elementRef.current) {
|
|
return;
|
|
}
|
|
|
|
const isRTL =
|
|
this.props.dir === "rtl" ||
|
|
getComputedStyle(this.elementRef.current).direction === "rtl";
|
|
|
|
if (this.state.isRTL !== isRTL) {
|
|
this.setState({ isRTL });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Focus the editor at the start of the content.
|
|
*/
|
|
public focusAtStart = () => {
|
|
const selection = Selection.atStart(this.view.state.doc);
|
|
const transaction = this.view.state.tr.setSelection(selection);
|
|
this.view.dispatch(transaction);
|
|
this.view.focus();
|
|
};
|
|
|
|
/**
|
|
* Focus the editor at the end of the content.
|
|
*/
|
|
public focusAtEnd = () => {
|
|
const selection = Selection.atEnd(this.view.state.doc);
|
|
const transaction = this.view.state.tr.setSelection(selection);
|
|
this.view.dispatch(transaction);
|
|
this.view.focus();
|
|
};
|
|
|
|
/**
|
|
* Focus the editor and scroll to the current selection.
|
|
*/
|
|
public focus = () => {
|
|
this.view.focus();
|
|
this.view.dispatch(this.view.state.tr.scrollIntoView());
|
|
};
|
|
|
|
/**
|
|
* Blur the editor.
|
|
*/
|
|
public blur = () => {
|
|
(this.view.dom as HTMLElement).blur();
|
|
|
|
// Have Safari remove the caret.
|
|
window?.getSelection()?.removeAllRanges();
|
|
};
|
|
|
|
/**
|
|
* Insert files at the current selection.
|
|
* =
|
|
* @param event The source event
|
|
* @param files The files to insert
|
|
* @returns True if the files were inserted
|
|
*/
|
|
public insertFiles = (
|
|
event: React.ChangeEvent<HTMLInputElement>,
|
|
files: File[]
|
|
) =>
|
|
insertFiles(
|
|
this.view,
|
|
event,
|
|
this.view.state.selection.to,
|
|
files,
|
|
this.props
|
|
);
|
|
|
|
/**
|
|
* Returns true if the trimmed content of the editor is an empty string.
|
|
*
|
|
* @returns True if the editor is empty
|
|
*/
|
|
public isEmpty = () => ProsemirrorHelper.isEmpty(this.view.state.doc);
|
|
|
|
/**
|
|
* Return the headings in the current editor.
|
|
*
|
|
* @returns A list of headings in the document
|
|
*/
|
|
public getHeadings = () => ProsemirrorHelper.getHeadings(this.view.state.doc);
|
|
|
|
/**
|
|
* Return the images in the current editor.
|
|
*
|
|
* @returns A list of images in the document
|
|
*/
|
|
public getImages = () => ProsemirrorHelper.getImages(this.view.state.doc);
|
|
|
|
public getLightboxImages = (): LightboxImage[] => {
|
|
const lightboxNodes = ProsemirrorHelper.getLightboxNodes(
|
|
this.view.state.doc
|
|
);
|
|
|
|
return map(lightboxNodes, (node) =>
|
|
LightboxImageFactory.createLightboxImage(this.view, node.pos)
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Return the tasks/checkmarks in the current editor.
|
|
*
|
|
* @returns A list of tasks in the document
|
|
*/
|
|
public getTasks = () => ProsemirrorHelper.getTasks(this.view.state.doc);
|
|
|
|
/**
|
|
* Return the comments in the current editor.
|
|
*
|
|
* @returns A list of comments in the document
|
|
*/
|
|
public getComments = () => ProsemirrorHelper.getComments(this.view.state.doc);
|
|
|
|
/**
|
|
* Remove all marks related to a specific comment from the document.
|
|
*
|
|
* @param commentId The id of the comment to remove
|
|
*/
|
|
public removeComment = (commentId: string) => {
|
|
const { state, dispatch } = this.view;
|
|
const tr = state.tr;
|
|
let markRemoved = false;
|
|
|
|
state.doc.descendants((node, pos) => {
|
|
if (markRemoved) {
|
|
return false;
|
|
}
|
|
const mark = node.marks.find(
|
|
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
|
);
|
|
|
|
if (mark) {
|
|
tr.removeMark(pos, pos + node.nodeSize, mark);
|
|
markRemoved = true;
|
|
return;
|
|
}
|
|
|
|
if (isArray(node.attrs?.marks)) {
|
|
const existingMarks = node.attrs.marks;
|
|
const updatedMarks = existingMarks.filter(
|
|
(mark: any) => mark.attrs.id !== commentId
|
|
);
|
|
const attrs = {
|
|
...node.attrs,
|
|
marks: updatedMarks,
|
|
};
|
|
tr.setNodeMarkup(pos, undefined, attrs);
|
|
markRemoved = true;
|
|
}
|
|
|
|
return;
|
|
});
|
|
|
|
dispatch(tr);
|
|
};
|
|
|
|
/**
|
|
* Update all marks related to a specific comment in the document.
|
|
*
|
|
* @param commentId The id of the comment to update
|
|
* @param attrs The attributes to update
|
|
*/
|
|
public updateComment = (
|
|
commentId: string,
|
|
attrs: { resolved?: boolean; draft?: boolean }
|
|
) => {
|
|
const { state, dispatch } = this.view;
|
|
const tr = state.tr;
|
|
let markUpdated = false;
|
|
|
|
state.doc.descendants((node, pos) => {
|
|
if (markUpdated) {
|
|
return false;
|
|
}
|
|
|
|
const mark = node.marks.find(
|
|
(m) => m.type === state.schema.marks.comment && m.attrs.id === commentId
|
|
);
|
|
|
|
if (mark) {
|
|
const from = pos;
|
|
const to = pos + node.nodeSize;
|
|
const newMark = state.schema.marks.comment.create({
|
|
...mark.attrs,
|
|
...attrs,
|
|
});
|
|
tr.removeMark(from, to, mark).addMark(from, to, newMark);
|
|
markUpdated = true;
|
|
return;
|
|
}
|
|
|
|
if (isArray(node.attrs?.marks)) {
|
|
const existingMarks = node.attrs.marks;
|
|
const updatedMarks = existingMarks.map((mark: any) =>
|
|
mark.type === "comment" && mark.attrs.id === commentId
|
|
? { ...mark, attrs: { ...mark.attrs, ...attrs } }
|
|
: mark
|
|
);
|
|
const newAttrs = {
|
|
...node.attrs,
|
|
marks: updatedMarks,
|
|
};
|
|
tr.setNodeMarkup(pos, undefined, newAttrs);
|
|
markUpdated = true;
|
|
}
|
|
|
|
return;
|
|
});
|
|
|
|
dispatch(tr);
|
|
};
|
|
|
|
public updateActiveLightboxImage = (activeImage: LightboxImage | null) => {
|
|
this.setState((state) => ({
|
|
...state,
|
|
activeLightboxImage: activeImage,
|
|
}));
|
|
};
|
|
|
|
/**
|
|
* Return the plain text content of the current editor.
|
|
*
|
|
* @returns A string of text
|
|
*/
|
|
public getPlainText = () => {
|
|
const { doc } = this.view.state;
|
|
|
|
return textBetween(doc, 0, doc.content.size);
|
|
};
|
|
|
|
private dispatchThemeChanged = (event: CustomEvent) => {
|
|
this.view.dispatch(this.view.state.tr.setMeta("theme", event.detail));
|
|
};
|
|
|
|
private handleChange = () => {
|
|
if (!this.props.onChange) {
|
|
return;
|
|
}
|
|
|
|
this.props.onChange((asString = true, trim = false) =>
|
|
this.view ? this.value(asString, trim) : undefined
|
|
);
|
|
};
|
|
|
|
private handleEditorInit = () => {
|
|
if (!this.props.onInit || this.isInitialized) {
|
|
return;
|
|
}
|
|
|
|
this.props.onInit();
|
|
this.isInitialized = true;
|
|
};
|
|
|
|
private handleEditorDestroy = () => {
|
|
if (!this.props.onDestroy) {
|
|
return;
|
|
}
|
|
this.props.onDestroy();
|
|
};
|
|
|
|
private handleEditorBlur = () => {
|
|
this.setState({ isEditorFocused: false });
|
|
return false;
|
|
};
|
|
|
|
private handleEditorFocus = () => {
|
|
this.setState({ isEditorFocused: true });
|
|
return false;
|
|
};
|
|
|
|
public render() {
|
|
const { readOnly, canUpdate, grow, style, className, onKeyDown } =
|
|
this.props;
|
|
const { isRTL } = this.state;
|
|
|
|
return (
|
|
<PortalContext.Provider value={this.wrapperRef.current}>
|
|
<EditorContext.Provider value={this}>
|
|
<Flex
|
|
ref={this.wrapperRef}
|
|
onKeyDown={onKeyDown}
|
|
style={style}
|
|
className={className}
|
|
align="flex-start"
|
|
justify="center"
|
|
column
|
|
>
|
|
<EditorContainer
|
|
rtl={isRTL}
|
|
grow={grow}
|
|
readOnly={readOnly}
|
|
readOnlyWriteCheckboxes={canUpdate}
|
|
focusedCommentId={this.props.focusedCommentId}
|
|
userId={this.props.userId}
|
|
editorStyle={this.props.editorStyle}
|
|
commenting={!!this.props.onClickCommentMark}
|
|
ref={this.elementRef}
|
|
lang={this.props.lang ?? ""}
|
|
/>
|
|
|
|
{this.widgets &&
|
|
Object.values(this.widgets).map((Widget, index) => (
|
|
<Widget
|
|
key={String(index)}
|
|
rtl={isRTL}
|
|
readOnly={readOnly}
|
|
selection={this.view.state.selection}
|
|
/>
|
|
))}
|
|
<Observer>
|
|
{() => (
|
|
<>{Array.from(this.renderers).map((view) => view.content)}</>
|
|
)}
|
|
</Observer>
|
|
</Flex>
|
|
{!isNull(this.state.activeLightboxImage) && (
|
|
<Lightbox
|
|
images={this.getLightboxImages()}
|
|
activeImage={this.state.activeLightboxImage}
|
|
onUpdate={this.updateActiveLightboxImage}
|
|
onClose={() => this.view.focus()}
|
|
/>
|
|
)}
|
|
</EditorContext.Provider>
|
|
</PortalContext.Provider>
|
|
);
|
|
}
|
|
}
|
|
|
|
const EditorContainer = styled(Styles)<{
|
|
userId?: string;
|
|
focusedCommentId?: string;
|
|
}>`
|
|
${(props) =>
|
|
props.focusedCommentId &&
|
|
css`
|
|
span#comment-${props.focusedCommentId} {
|
|
background: ${transparentize(0.5, props.theme.brand.marine)};
|
|
border-bottom: 2px solid ${props.theme.commentMarkBackground};
|
|
}
|
|
a#comment-${props.focusedCommentId}
|
|
~ span.component-image
|
|
div.image-wrapper {
|
|
outline: ${props.theme.commentMarkBackground} solid 2px;
|
|
}
|
|
`}
|
|
|
|
${(props) =>
|
|
props.userId &&
|
|
css`
|
|
.mention[data-id="${props.userId}"] {
|
|
color: ${props.theme.textHighlightForeground};
|
|
background: ${props.theme.textHighlight};
|
|
|
|
&.ProseMirror-selectednode {
|
|
outline-color: ${props.readOnly
|
|
? "transparent"
|
|
: darken(0.2, props.theme.textHighlight)};
|
|
}
|
|
}
|
|
`}
|
|
`;
|
|
|
|
const LazyLoadedEditor = React.forwardRef<Editor, Props>(
|
|
function LazyLoadedEditor_(props: Props, ref) {
|
|
return (
|
|
<WithTheme>
|
|
{(theme) => <Editor theme={theme} {...props} ref={ref} />}
|
|
</WithTheme>
|
|
);
|
|
}
|
|
);
|
|
|
|
const observe = (
|
|
selector: string,
|
|
callback: (element: HTMLElement) => void,
|
|
targetNode = document.body
|
|
) => {
|
|
const observer = new MutationObserver((mutations) => {
|
|
const match = [...mutations]
|
|
.flatMap((mutation) => [...mutation.addedNodes])
|
|
.find((node: HTMLElement) => node.matches?.(selector));
|
|
if (match) {
|
|
callback(match as HTMLElement);
|
|
}
|
|
});
|
|
|
|
if (targetNode.querySelector(selector)) {
|
|
callback(targetNode.querySelector(selector) as HTMLElement);
|
|
} else {
|
|
observer.observe(targetNode, { childList: true, subtree: true });
|
|
}
|
|
|
|
return observer;
|
|
};
|
|
|
|
export default LazyLoadedEditor;
|