mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
chore: Update editor generics (#12247)
* chore: Update editor generics * fix: Address PR review on editor generics - Restore null-guard on Link click handler so anchors aren't inert when no onClickLink is provided - Mark onClickLink optional in LinkOptions and openLink command to match runtime - Remove dead `collapsed` option from HeadingOptions - Make ToggleBlock dictionary optional and restore optional-chained access for server-side schema instantiation Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,10 +6,20 @@ import ReactDOM from "react-dom";
|
||||
import type { WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import { PlaceholderPlugin } from "@shared/editor/plugins/PlaceholderPlugin";
|
||||
import { findParentNode } from "@shared/editor/queries/findParentNode";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import type { SuggestionOptions } from "~/editor/extensions/Suggestion";
|
||||
import Suggestion from "~/editor/extensions/Suggestion";
|
||||
import BlockMenu from "../components/BlockMenu";
|
||||
|
||||
export default class BlockMenuExtension extends Suggestion {
|
||||
/**
|
||||
* Options for the BlockMenu extension.
|
||||
*/
|
||||
type BlockMenuOptions = SuggestionOptions & {
|
||||
/** A dictionary of translated strings used in the editor. */
|
||||
dictionary: Dictionary;
|
||||
};
|
||||
|
||||
export default class BlockMenuExtension extends Suggestion<BlockMenuOptions> {
|
||||
get defaultOptions() {
|
||||
return {
|
||||
trigger: "/",
|
||||
|
||||
@@ -18,12 +18,22 @@ const pluginKey = new PluginKey("find-and-replace");
|
||||
const supportsHighlightAPI =
|
||||
typeof CSS !== "undefined" && CSS.highlights !== undefined;
|
||||
|
||||
export default class FindAndReplaceExtension extends Extension {
|
||||
/**
|
||||
* Options for the FindAndReplace extension.
|
||||
*/
|
||||
type FindAndReplaceOptions = {
|
||||
/** Whether the search should be case sensitive by default. */
|
||||
caseSensitive: boolean;
|
||||
/** Whether the search query should be interpreted as a regular expression by default. */
|
||||
regexEnabled: boolean;
|
||||
};
|
||||
|
||||
export default class FindAndReplaceExtension extends Extension<FindAndReplaceOptions> {
|
||||
public get name() {
|
||||
return "find-and-replace";
|
||||
}
|
||||
|
||||
public get defaultOptions() {
|
||||
public get defaultOptions(): FindAndReplaceOptions {
|
||||
return {
|
||||
caseSensitive: false,
|
||||
regexEnabled: false,
|
||||
|
||||
@@ -7,12 +7,15 @@ import stores from "~/stores";
|
||||
import HoverPreview from "~/components/HoverPreview";
|
||||
import env from "~/env";
|
||||
|
||||
/**
|
||||
* Options for the HoverPreviews extension.
|
||||
*/
|
||||
interface HoverPreviewsOptions {
|
||||
/** Delay before the target is considered "hovered" and callback is triggered. */
|
||||
/** Delay in milliseconds before the target is considered "hovered" and the preview is shown. */
|
||||
delay: number;
|
||||
}
|
||||
|
||||
export default class HoverPreviews extends Extension {
|
||||
export default class HoverPreviews extends Extension<HoverPreviewsOptions> {
|
||||
state: {
|
||||
activeLinkElement: HTMLElement | null;
|
||||
unfurlId: string | null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import {
|
||||
@@ -20,7 +21,19 @@ type UserAwareness = {
|
||||
head: object;
|
||||
};
|
||||
|
||||
export default class Multiplayer extends Extension {
|
||||
/**
|
||||
* Options for the Multiplayer extension.
|
||||
*/
|
||||
type MultiplayerOptions = {
|
||||
/** The local user, used for cursor presence and the persistent user/client mapping. */
|
||||
user: { id: string; color: string };
|
||||
/** The Hocuspocus provider used for awareness and document sync. */
|
||||
provider: HocuspocusProvider;
|
||||
/** The shared Yjs document this editor is bound to. */
|
||||
document: Y.Doc;
|
||||
};
|
||||
|
||||
export default class Multiplayer extends Extension<MultiplayerOptions> {
|
||||
get name() {
|
||||
return "multiplayer";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { InputRule } from "@shared/editor/lib/InputRule";
|
||||
import type { UserPreferences } from "@shared/types";
|
||||
|
||||
const rightArrow = new InputRule(/->$/, "→");
|
||||
// Note that the suppression of pipe here prevents conflict with table creation rule.
|
||||
@@ -19,7 +20,15 @@ const closeDoubleQuote = new InputRule(/^(?!.*`)[\s\S]*(")$/, "”");
|
||||
const openSingleQuote = new InputRule(/(?:^|[\s{[(<'"\u2018\u201C])(')$/, "‘");
|
||||
const closeSingleQuote = new InputRule(/^(?!.*`)[\s\S]*(')$/, "’");
|
||||
|
||||
export default class SmartText extends Extension {
|
||||
/**
|
||||
* Options for the SmartText extension.
|
||||
*/
|
||||
type SmartTextOptions = {
|
||||
/** Display preferences for the logged in user, if any. */
|
||||
userPreferences?: UserPreferences | null;
|
||||
};
|
||||
|
||||
export default class SmartText extends Extension<SmartTextOptions> {
|
||||
get name() {
|
||||
return "smart_text";
|
||||
}
|
||||
|
||||
@@ -7,15 +7,25 @@ import Extension from "@shared/editor/lib/Extension";
|
||||
import { SuggestionsMenuPlugin } from "@shared/editor/plugins/SuggestionsMenuPlugin";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
|
||||
type Options = {
|
||||
/**
|
||||
* Options shared by all suggestion-style extensions (block menu, emoji menu,
|
||||
* mention menu).
|
||||
*/
|
||||
export type SuggestionOptions = {
|
||||
/** Whether the suggestion menu is allowed to open inside code blocks or inline code. */
|
||||
enabledInCode: boolean;
|
||||
/** Character (or list of characters) that opens the suggestion menu. */
|
||||
trigger: string | string[];
|
||||
/** Whether spaces are allowed inside the search term. */
|
||||
allowSpaces: boolean;
|
||||
/** Whether the menu only opens once at least one character has been typed after the trigger. */
|
||||
requireSearchTerm: boolean;
|
||||
};
|
||||
|
||||
export default class Suggestion extends Extension {
|
||||
constructor(options: Options) {
|
||||
export default class Suggestion<
|
||||
TOptions extends SuggestionOptions = SuggestionOptions,
|
||||
> extends Extension<TOptions> {
|
||||
constructor(options: TOptions) {
|
||||
super(options);
|
||||
|
||||
const triggers = Array.isArray(this.options.trigger)
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type Extension from "@shared/editor/lib/Extension";
|
||||
import type Mark from "@shared/editor/marks/Mark";
|
||||
import type Node from "@shared/editor/nodes/Node";
|
||||
import type { AnyExtensionClass } from "@shared/editor/lib/types";
|
||||
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
|
||||
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
|
||||
import DiagramsExtension from "@shared/editor/extensions/Diagrams";
|
||||
@@ -14,7 +12,7 @@ import PreventTab from "~/editor/extensions/PreventTab";
|
||||
import SelectionToolbarExtension from "~/editor/extensions/SelectionToolbar";
|
||||
import SmartText from "~/editor/extensions/SmartText";
|
||||
|
||||
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
|
||||
type Nodes = AnyExtensionClass[];
|
||||
|
||||
export const withUIExtensions = (nodes: Nodes) => [
|
||||
...nodes,
|
||||
|
||||
@@ -29,13 +29,11 @@ import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import Styles from "@shared/editor/components/Styles";
|
||||
import type { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import type { CommandFactory, WidgetProps } from "@shared/editor/lib/Extension";
|
||||
import type Extension from "@shared/editor/lib/Extension";
|
||||
import type { AnyExtension, AnyExtensionClass } from "@shared/editor/lib/types";
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import type { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import type Mark from "@shared/editor/marks/Mark";
|
||||
import { basicExtensions as extensions } from "@shared/editor/nodes";
|
||||
import type Node from "@shared/editor/nodes/Node";
|
||||
import type ReactNode from "@shared/editor/nodes/ReactNode";
|
||||
import type { ComponentProps } from "@shared/editor/types";
|
||||
import type {
|
||||
@@ -75,7 +73,7 @@ export type Props = {
|
||||
/** Placeholder displayed when the editor is empty */
|
||||
placeholder: string;
|
||||
/** Extensions to load into the editor */
|
||||
extensions?: (typeof Node | typeof Mark | typeof Extension | Extension)[];
|
||||
extensions?: (AnyExtensionClass | AnyExtension)[];
|
||||
/** If the editor should be focused on mount */
|
||||
autoFocus?: boolean;
|
||||
/** The focused comment, if any */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { chainCommands, toggleMark } from "prosemirror-commands";
|
||||
import type { Attrs } from "prosemirror-model";
|
||||
import type { Command } from "prosemirror-state";
|
||||
import { NodeSelection, Selection, TextSelection } from "prosemirror-state";
|
||||
import type * as React from "react";
|
||||
import { getMarkRange } from "../queries/getMarkRange";
|
||||
import { toast } from "sonner";
|
||||
import { sanitizeUrl } from "@shared/utils/urls";
|
||||
@@ -49,8 +50,16 @@ const addLinkNodeSelection =
|
||||
|
||||
const openLinkTextSelection =
|
||||
(
|
||||
onClickLink: (url: string, event: KeyboardEvent) => void,
|
||||
dictionary: Record<string, string>
|
||||
onClickLink:
|
||||
| ((
|
||||
url: string,
|
||||
event?:
|
||||
| KeyboardEvent
|
||||
| MouseEvent
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
) => void)
|
||||
| undefined,
|
||||
dictionary: { openLinkError: string }
|
||||
): Command =>
|
||||
(state) => {
|
||||
if (!(state.selection instanceof TextSelection)) {
|
||||
@@ -72,8 +81,16 @@ const openLinkTextSelection =
|
||||
|
||||
const openLinkNodeSelection =
|
||||
(
|
||||
onClickLink: (url: string, event: KeyboardEvent) => void,
|
||||
dictionary: Record<string, string>
|
||||
onClickLink:
|
||||
| ((
|
||||
url: string,
|
||||
event?:
|
||||
| KeyboardEvent
|
||||
| MouseEvent
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
) => void)
|
||||
| undefined,
|
||||
dictionary: { openLinkError: string }
|
||||
): Command =>
|
||||
(state) => {
|
||||
if (!(state.selection instanceof NodeSelection)) {
|
||||
@@ -238,8 +255,13 @@ export const addLink = (attrs: Attrs): Command =>
|
||||
chainCommands(addLinkTextSelection(attrs), addLinkNodeSelection(attrs));
|
||||
|
||||
export const openLink = (
|
||||
onClickLink: (url: string, event: KeyboardEvent) => void,
|
||||
dictionary: Record<string, string>
|
||||
onClickLink:
|
||||
| ((
|
||||
url: string,
|
||||
event?: KeyboardEvent | MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
) => void)
|
||||
| undefined,
|
||||
dictionary: { openLinkError: string }
|
||||
): Command =>
|
||||
chainCommands(
|
||||
openLinkTextSelection(onClickLink, dictionary),
|
||||
|
||||
@@ -13,12 +13,34 @@ import { toggleFoldPluginKey } from "../nodes/ToggleBlock";
|
||||
|
||||
const pluginKey = new PluginKey("diffs");
|
||||
|
||||
export default class Diff extends Extension {
|
||||
/**
|
||||
* Options for the Diff extension.
|
||||
*/
|
||||
type DiffOptions = {
|
||||
/** The set of changes to render as decorations, or null to disable diff rendering. */
|
||||
changes: readonly ExtendedChange[] | null;
|
||||
/** CSS class applied to inline insertions. */
|
||||
insertionClassName: string;
|
||||
/** CSS class applied to inline deletions. */
|
||||
deletionClassName: string;
|
||||
/** CSS class applied to whole-node insertions. */
|
||||
nodeInsertionClassName: string;
|
||||
/** CSS class applied to whole-node deletions. */
|
||||
nodeDeletionClassName: string;
|
||||
/** CSS class applied to inline modifications. */
|
||||
modificationClassName: string;
|
||||
/** CSS class applied to whole-node modifications. */
|
||||
nodeModificationClassName: string;
|
||||
/** CSS class applied to the change currently focused via navigation. */
|
||||
currentChangeClassName: string;
|
||||
};
|
||||
|
||||
export default class Diff extends Extension<DiffOptions> {
|
||||
get name() {
|
||||
return "diff";
|
||||
}
|
||||
|
||||
get defaultOptions() {
|
||||
get defaultOptions(): DiffOptions {
|
||||
return {
|
||||
changes: null,
|
||||
insertionClassName: EditorStyleHelper.diffInsertion,
|
||||
@@ -60,7 +82,7 @@ export default class Diff extends Extension {
|
||||
* @returns the total count of all inserted, deleted, and modified items.
|
||||
*/
|
||||
public getTotalChangesCount(): number {
|
||||
const { changes } = this.options as { changes: ExtendedChange[] | null };
|
||||
const { changes } = this.options;
|
||||
if (!changes) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,15 @@ import type { Transaction } from "prosemirror-state";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
import Extension from "../lib/Extension";
|
||||
|
||||
export default class MaxLength extends Extension {
|
||||
/**
|
||||
* Options for the MaxLength extension.
|
||||
*/
|
||||
type MaxLengthOptions = {
|
||||
/** Maximum allowed document size, in ProseMirror node size units. */
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
export default class MaxLength extends Extension<MaxLengthOptions> {
|
||||
get name() {
|
||||
return "maxlength";
|
||||
}
|
||||
|
||||
@@ -2,12 +2,22 @@ import type { NodeType } from "prosemirror-model";
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "../lib/Extension";
|
||||
|
||||
export default class TrailingNode extends Extension {
|
||||
/**
|
||||
* Options for the TrailingNode extension.
|
||||
*/
|
||||
type TrailingNodeOptions = {
|
||||
/** Name of the node type to insert as the trailing node. */
|
||||
node: string;
|
||||
/** Node names after which a trailing node should not be inserted. */
|
||||
notAfter: string[];
|
||||
};
|
||||
|
||||
export default class TrailingNode extends Extension<TrailingNodeOptions> {
|
||||
get name() {
|
||||
return "trailing_node";
|
||||
}
|
||||
|
||||
get defaultOptions() {
|
||||
get defaultOptions(): TrailingNodeOptions {
|
||||
return {
|
||||
node: "paragraph",
|
||||
notAfter: ["paragraph", "heading"],
|
||||
|
||||
@@ -12,17 +12,15 @@ export type WidgetProps = {
|
||||
selection?: Selection;
|
||||
};
|
||||
|
||||
export default class Extension {
|
||||
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
options: any;
|
||||
export default class Extension<TOptions extends object = object> {
|
||||
options: TOptions;
|
||||
editor: Editor;
|
||||
|
||||
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
constructor(options: Record<string, any> = {}) {
|
||||
constructor(options: Partial<TOptions> = {}) {
|
||||
this.options = {
|
||||
...this.defaultOptions,
|
||||
...options,
|
||||
};
|
||||
} as TOptions;
|
||||
}
|
||||
|
||||
bindEditor(editor: Editor) {
|
||||
@@ -45,7 +43,7 @@ export default class Extension {
|
||||
return [];
|
||||
}
|
||||
|
||||
get defaultOptions() {
|
||||
get defaultOptions(): Partial<TOptions> {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,26 +10,22 @@ import type Mark from "../marks/Mark";
|
||||
import type Node from "../nodes/Node";
|
||||
import type { CommandFactory, WidgetProps } from "./Extension";
|
||||
import type Extension from "./Extension";
|
||||
import type { AnyExtension, AnyExtensionClass } from "./types";
|
||||
import makeRules from "./markdown/rules";
|
||||
import { MarkdownSerializer } from "./markdown/serializer";
|
||||
|
||||
export default class ExtensionManager {
|
||||
extensions: (Node | Mark | Extension)[] = [];
|
||||
extensions: AnyExtension[] = [];
|
||||
readOnly: boolean;
|
||||
|
||||
constructor(
|
||||
extensions: (
|
||||
| Extension
|
||||
| typeof Node
|
||||
| typeof Mark
|
||||
| typeof Extension
|
||||
)[] = [],
|
||||
extensions: (AnyExtensionClass | AnyExtension)[] = [],
|
||||
editor?: Editor
|
||||
) {
|
||||
this.readOnly = editor?.props.readOnly ?? false;
|
||||
|
||||
extensions.forEach((ext) => {
|
||||
let extension;
|
||||
let extension: AnyExtension;
|
||||
|
||||
if (typeof ext === "function") {
|
||||
// Check the prototype before instantiation to avoid constructor cost
|
||||
@@ -42,8 +38,12 @@ export default class ExtensionManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error We won't instantiate an abstract class
|
||||
extension = new ext(editor?.props);
|
||||
// Cast away abstract: registration treats all classes uniformly and
|
||||
// concrete subclasses are required at the public boundary.
|
||||
const Ctor = ext as new (
|
||||
options?: Record<string, unknown>
|
||||
) => AnyExtension;
|
||||
extension = new Ctor(editor?.props);
|
||||
} else {
|
||||
// For already-instantiated extensions, check the instance.
|
||||
if (this.readOnly && ext.type === "extension" && !ext.allowInReadOnly) {
|
||||
@@ -205,35 +205,40 @@ export default class ExtensionManager {
|
||||
keymaps({ schema }: { schema: Schema }) {
|
||||
const keymaps = this.extensions
|
||||
.filter((extension) => extension.keys)
|
||||
.map((extension) =>
|
||||
["node", "mark"].includes(extension.type)
|
||||
? extension.keys({
|
||||
// @ts-expect-error TODO
|
||||
type: schema[`${extension.type}s`][extension.name],
|
||||
schema,
|
||||
})
|
||||
: (extension as Extension).keys({ schema })
|
||||
);
|
||||
.map((extension) => {
|
||||
if (extension.type === "node") {
|
||||
const node = extension as Node;
|
||||
return node.keys({ type: schema.nodes[node.name], schema });
|
||||
}
|
||||
if (extension.type === "mark") {
|
||||
const mark = extension as Mark;
|
||||
return mark.keys({ type: schema.marks[mark.name], schema });
|
||||
}
|
||||
return (extension as Extension).keys({ schema });
|
||||
});
|
||||
|
||||
return keymaps.map(keymap);
|
||||
}
|
||||
|
||||
inputRules({ schema }: { schema: Schema }) {
|
||||
const extensionInputRules = this.extensions
|
||||
.filter((extension) => ["extension"].includes(extension.type))
|
||||
.filter((extension) => extension.type === "extension")
|
||||
.filter((extension) => extension.inputRules)
|
||||
.map((extension: Extension) => extension.inputRules({ schema }));
|
||||
|
||||
const nodeMarkInputRules = this.extensions
|
||||
.filter((extension) => ["node", "mark"].includes(extension.type))
|
||||
.filter(
|
||||
(extension) => extension.type === "node" || extension.type === "mark"
|
||||
)
|
||||
.filter((extension) => extension.inputRules)
|
||||
.map((extension) =>
|
||||
extension.inputRules({
|
||||
// @ts-expect-error TODO
|
||||
type: schema[`${extension.type}s`][extension.name],
|
||||
schema,
|
||||
})
|
||||
);
|
||||
.map((extension) => {
|
||||
if (extension.type === "node") {
|
||||
const node = extension as Node;
|
||||
return node.inputRules({ type: schema.nodes[node.name], schema });
|
||||
}
|
||||
const mark = extension as Mark;
|
||||
return mark.inputRules({ type: schema.marks[mark.name], schema });
|
||||
});
|
||||
|
||||
return [...extensionInputRules, ...nodeMarkInputRules].reduce(
|
||||
(allInputRules, inputRules) => [...allInputRules, ...inputRules],
|
||||
@@ -245,19 +250,19 @@ export default class ExtensionManager {
|
||||
return this.extensions
|
||||
.filter((extension) => extension.commands)
|
||||
.reduce((allCommands, extension) => {
|
||||
const { name, type } = extension;
|
||||
const { name } = extension;
|
||||
const commands: Record<string, CommandFactory> = {};
|
||||
|
||||
// @ts-expect-error FIXME
|
||||
const value = extension.commands({
|
||||
schema,
|
||||
...(["node", "mark"].includes(type)
|
||||
? {
|
||||
// @ts-expect-error TODO
|
||||
type: schema[`${type}s`][name],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
let value: ReturnType<Extension["commands"]>;
|
||||
if (extension.type === "node") {
|
||||
const node = extension as Node;
|
||||
value = node.commands({ schema, type: schema.nodes[node.name] });
|
||||
} else if (extension.type === "mark") {
|
||||
const mark = extension as Mark;
|
||||
value = mark.commands({ schema, type: schema.marks[mark.name] });
|
||||
} else {
|
||||
value = (extension as Extension).commands({ schema });
|
||||
}
|
||||
|
||||
const apply = (
|
||||
callback: CommandFactory,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type Mark from "../marks/Mark";
|
||||
import type Node from "../nodes/Node";
|
||||
import type Extension from "./Extension";
|
||||
|
||||
/**
|
||||
* Type-erased Extension, Mark, or Node instance. Used at registration
|
||||
* boundaries where the specific options shape is not relevant.
|
||||
*/
|
||||
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type AnyExtension = Extension<any> | Mark<any> | Node<any>;
|
||||
|
||||
/**
|
||||
* Constructor for any Extension, Mark, or Node subclass, regardless of its
|
||||
* options shape.
|
||||
*/
|
||||
export type AnyExtensionClass = abstract new (
|
||||
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...args: any[]
|
||||
) => AnyExtension;
|
||||
@@ -10,7 +10,25 @@ import { isMarkActive } from "../queries/isMarkActive";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
import Mark from "./Mark";
|
||||
|
||||
export default class Comment extends Mark {
|
||||
/**
|
||||
* Options for the Comment mark.
|
||||
*/
|
||||
type CommentOptions = {
|
||||
/** The id of the current user, recorded on newly created comment marks. */
|
||||
userId?: string;
|
||||
/** Callback invoked when a comment mark is created in the document. */
|
||||
onCreateCommentMark?: (
|
||||
commentId: string,
|
||||
userId: string,
|
||||
options?: { focus: boolean }
|
||||
) => void;
|
||||
/** Callback invoked when an existing comment mark is clicked. */
|
||||
onClickCommentMark?: (commentId: string) => void;
|
||||
/** Callback invoked to request that the comments sidebar be opened. */
|
||||
onOpenCommentsSidebar?: () => void;
|
||||
};
|
||||
|
||||
export default class Comment extends Mark<CommentOptions> {
|
||||
get name() {
|
||||
return "comment";
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Command, EditorState } from "prosemirror-state";
|
||||
import { Plugin, TextSelection } from "prosemirror-state";
|
||||
import type { EditorView } from "prosemirror-view";
|
||||
import { toast } from "sonner";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { isUrl, sanitizeUrl } from "../../utils/urls";
|
||||
import { getMarkRange } from "../queries/getMarkRange";
|
||||
import Mark from "./Mark";
|
||||
@@ -53,7 +54,20 @@ function isPlainURL(
|
||||
return !link.isInSet(next.marks);
|
||||
}
|
||||
|
||||
export default class Link extends Mark {
|
||||
/**
|
||||
* Options for the Link mark.
|
||||
*/
|
||||
type LinkOptions = {
|
||||
/** A dictionary of translated strings used in the editor. */
|
||||
dictionary: Dictionary;
|
||||
/** Callback invoked when the user clicks any link in the document. */
|
||||
onClickLink?: (
|
||||
href: string,
|
||||
event?: MouseEvent | React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
};
|
||||
|
||||
export default class Link extends Mark<LinkOptions> {
|
||||
get name() {
|
||||
return "link";
|
||||
}
|
||||
@@ -208,10 +222,11 @@ export default class Link extends Mark {
|
||||
: "");
|
||||
|
||||
try {
|
||||
if (this.options.onClickLink && href) {
|
||||
const sanitized = sanitizeUrl(href);
|
||||
if (this.options.onClickLink && sanitized) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.options.onClickLink(sanitizeUrl(href), event);
|
||||
this.options.onClickLink(sanitized, event);
|
||||
}
|
||||
} catch (_err) {
|
||||
toast.error(this.options.dictionary.openLinkError);
|
||||
|
||||
@@ -13,7 +13,9 @@ import Extension from "../lib/Extension";
|
||||
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import type { Primitive } from "utility-types";
|
||||
|
||||
export default abstract class Mark extends Extension {
|
||||
export default abstract class Mark<
|
||||
TOptions extends object = object,
|
||||
> extends Extension<TOptions> {
|
||||
get type() {
|
||||
return "mark";
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Command } from "prosemirror-state";
|
||||
import { NodeSelection } from "prosemirror-state";
|
||||
import { Trans } from "react-i18next";
|
||||
import type { Primitive } from "utility-types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { bytesToHumanReadable, getEventFiles } from "../../utils/files";
|
||||
import { sanitizeUrl } from "../../utils/urls";
|
||||
import insertFiles from "../commands/insertFiles";
|
||||
@@ -21,7 +22,15 @@ import type { ComponentProps } from "../types";
|
||||
import Node from "./Node";
|
||||
import PdfViewer from "../components/PDF";
|
||||
|
||||
export default class Attachment extends Node {
|
||||
/**
|
||||
* Options for the Attachment node.
|
||||
*/
|
||||
type AttachmentOptions = {
|
||||
/** A dictionary of translated strings used in the editor. */
|
||||
dictionary: Dictionary;
|
||||
};
|
||||
|
||||
export default class Attachment extends Node<AttachmentOptions> {
|
||||
get name() {
|
||||
return "attachment";
|
||||
}
|
||||
|
||||
@@ -145,17 +145,21 @@ function buildCollapseState(
|
||||
};
|
||||
}
|
||||
|
||||
export default class CodeFence extends Node {
|
||||
/**
|
||||
* Options for the CodeFence node.
|
||||
*/
|
||||
type CodeFenceOptions = {
|
||||
/** A dictionary of translated strings used in the editor. */
|
||||
dictionary: Dictionary;
|
||||
/** Display preferences for the logged in user, if any. */
|
||||
userPreferences?: UserPreferences | null;
|
||||
};
|
||||
|
||||
export default class CodeFence extends Node<CodeFenceOptions> {
|
||||
/** Plugin key for the collapse state, shared with the command. */
|
||||
private static readonly collapseKey = new PluginKey<CollapseState>(
|
||||
"collapse-code-block"
|
||||
);
|
||||
constructor(options: {
|
||||
dictionary: Dictionary;
|
||||
userPreferences?: UserPreferences | null;
|
||||
}) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
get showLineNumbers(): boolean {
|
||||
return this.options.userPreferences?.codeBlockLineNumbers ?? true;
|
||||
|
||||
@@ -3,7 +3,15 @@ import type { NodeSpec } from "prosemirror-model";
|
||||
import { PlaceholderPlugin } from "../plugins/PlaceholderPlugin";
|
||||
import Node from "./Node";
|
||||
|
||||
export default class Doc extends Node {
|
||||
/**
|
||||
* Options for the Doc node.
|
||||
*/
|
||||
type DocOptions = {
|
||||
/** Placeholder text shown when the document is empty. */
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export default class Doc extends Node<DocOptions> {
|
||||
get name() {
|
||||
return "doc";
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import { toast } from "sonner";
|
||||
import type { Primitive } from "utility-types";
|
||||
import { isSafari } from "../../utils/browser";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import Storage from "../../utils/Storage";
|
||||
import backspaceToParagraph from "../commands/backspaceToParagraph";
|
||||
import splitHeading from "../commands/splitHeading";
|
||||
@@ -30,15 +31,26 @@ export enum HeadingLevel {
|
||||
Four,
|
||||
}
|
||||
|
||||
export default class Heading extends Node {
|
||||
/**
|
||||
* Options for the Heading node.
|
||||
*/
|
||||
type HeadingOptions = {
|
||||
/** Heading levels (1-based) that are enabled in this editor. */
|
||||
levels: number[];
|
||||
/** Offset added to the rendered heading level (e.g. 1 renders an `h2` for level 1). */
|
||||
offset?: number;
|
||||
/** A dictionary of translated strings used in the editor. */
|
||||
dictionary: Dictionary;
|
||||
};
|
||||
|
||||
export default class Heading extends Node<HeadingOptions> {
|
||||
get name() {
|
||||
return "heading";
|
||||
}
|
||||
|
||||
get defaultOptions() {
|
||||
get defaultOptions(): Partial<HeadingOptions> {
|
||||
return {
|
||||
levels: [1, 2, 3, 4],
|
||||
collapsed: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ import type { CommandFactory } from "../lib/Extension";
|
||||
import Extension from "../lib/Extension";
|
||||
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
|
||||
export default abstract class Node extends Extension {
|
||||
export default abstract class Node<
|
||||
TOptions extends object = object,
|
||||
> extends Extension<TOptions> {
|
||||
get type() {
|
||||
return "node";
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ComponentProps } from "../types";
|
||||
import Node from "./Node";
|
||||
|
||||
export default abstract class ReactNode extends Node {
|
||||
export default abstract class ReactNode<
|
||||
TOptions extends object = object,
|
||||
> extends Node<TOptions> {
|
||||
abstract component: (
|
||||
props: Omit<ComponentProps, "theme">
|
||||
) => React.ReactElement;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
|
||||
import { findWrapping } from "prosemirror-transform";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import { v4 } from "uuid";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import Storage from "../../utils/Storage";
|
||||
import {
|
||||
deleteSelectionPreservingBody,
|
||||
@@ -64,7 +65,15 @@ export const toggleEventPluginKey = new PluginKey("toggleBlockEvent");
|
||||
/** Build the localStorage key used to persist a toggle block's fold state. */
|
||||
export const toggleStorageKey = (id: string) => `toggle:${id}`;
|
||||
|
||||
export default class ToggleBlock extends Node {
|
||||
/**
|
||||
* Options for the ToggleBlock node.
|
||||
*/
|
||||
type ToggleBlockOptions = {
|
||||
/** A dictionary of translated strings used in the editor. */
|
||||
dictionary?: Dictionary;
|
||||
};
|
||||
|
||||
export default class ToggleBlock extends Node<ToggleBlockOptions> {
|
||||
get name() {
|
||||
return "container_toggle";
|
||||
}
|
||||
@@ -340,7 +349,7 @@ export default class ToggleBlock extends Node {
|
||||
parent.type.name === "container_toggle" &&
|
||||
$start.index($start.depth - 1) === 0 &&
|
||||
node.textContent === "",
|
||||
text: this.options.dictionary?.emptyToggleBlockHead,
|
||||
text: this.options.dictionary?.emptyToggleBlockHead ?? "",
|
||||
},
|
||||
{
|
||||
condition: ({ parent, $start, state }) =>
|
||||
@@ -350,7 +359,7 @@ export default class ToggleBlock extends Node {
|
||||
ToggleBlock.isBodyEmpty(parent) &&
|
||||
(state.selection.$from.pos < $start.pos ||
|
||||
state.selection.$from.pos > $start.end($start.depth - 1)),
|
||||
text: this.options.dictionary?.emptyToggleBlockBody,
|
||||
text: this.options.dictionary?.emptyToggleBlockBody ?? "",
|
||||
},
|
||||
{
|
||||
condition: ({ node, parent, $start, state }) =>
|
||||
@@ -359,7 +368,7 @@ export default class ToggleBlock extends Node {
|
||||
node.isTextblock &&
|
||||
node.textContent === "" &&
|
||||
(state.selection as TextSelection).$cursor?.pos === $start.pos,
|
||||
text: this.options.dictionary?.newLineEmpty,
|
||||
text: this.options.dictionary?.newLineEmpty ?? "",
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
import { NodeSelection, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import type { Primitive } from "utility-types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { sanitizeUrl } from "../../utils/urls";
|
||||
import toggleWrap from "../commands/toggleWrap";
|
||||
import Caption from "../components/Caption";
|
||||
@@ -16,7 +17,15 @@ import attachmentsRule from "../rules/links";
|
||||
import type { ComponentProps } from "../types";
|
||||
import Node from "./Node";
|
||||
|
||||
export default class Video extends Node {
|
||||
/**
|
||||
* Options for the Video node.
|
||||
*/
|
||||
type VideoOptions = {
|
||||
/** A dictionary of translated strings used in the editor. */
|
||||
dictionary: Dictionary;
|
||||
};
|
||||
|
||||
export default class Video extends Node<VideoOptions> {
|
||||
get name() {
|
||||
return "video";
|
||||
}
|
||||
|
||||
@@ -4,14 +4,13 @@ import HexColorPreview from "../extensions/HexColorPreview";
|
||||
import History from "../extensions/History";
|
||||
import MaxLength from "../extensions/MaxLength";
|
||||
import TrailingNode from "../extensions/TrailingNode";
|
||||
import type Extension from "../lib/Extension";
|
||||
import type { AnyExtensionClass } from "../lib/types";
|
||||
import Bold from "../marks/Bold";
|
||||
import Code from "../marks/Code";
|
||||
import Comment from "../marks/Comment";
|
||||
import Highlight from "../marks/Highlight";
|
||||
import Italic from "../marks/Italic";
|
||||
import Link from "../marks/Link";
|
||||
import type Mark from "../marks/Mark";
|
||||
import TemplatePlaceholder from "../marks/Placeholder";
|
||||
import Strikethrough from "../marks/Strikethrough";
|
||||
import Underline from "../marks/Underline";
|
||||
@@ -33,7 +32,6 @@ import ListItem from "./ListItem";
|
||||
import Math from "./Math";
|
||||
import MathBlock from "./MathBlock";
|
||||
import Mention from "./Mention";
|
||||
import type Node from "./Node";
|
||||
import Notice from "./Notice";
|
||||
import OrderedList from "./OrderedList";
|
||||
import Paragraph from "./Paragraph";
|
||||
@@ -47,7 +45,7 @@ import ToggleBlock from "./ToggleBlock";
|
||||
|
||||
import Video from "./Video";
|
||||
|
||||
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
|
||||
type Nodes = AnyExtensionClass[];
|
||||
|
||||
/**
|
||||
* A set of inline nodes that are used in the editor. This is used for simple
|
||||
|
||||
Reference in New Issue
Block a user