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:
Tom Moor
2026-05-02 18:54:27 -04:00
committed by GitHub
parent cae8c78eb9
commit 8c716b173a
27 changed files with 327 additions and 104 deletions
+11 -1
View File
@@ -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: "/",
+12 -2
View File
@@ -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,
+5 -2
View File
@@ -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;
+14 -1
View File
@@ -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";
}
+10 -1
View File
@@ -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";
}
+13 -3
View File
@@ -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)
+2 -4
View File
@@ -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,
+2 -4
View File
@@ -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 */
+28 -6
View File
@@ -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),
+25 -3
View File
@@ -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;
}
+9 -1
View File
@@ -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";
}
+12 -2
View File
@@ -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"],
+5 -7
View File
@@ -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 {};
}
+43 -38
View File
@@ -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,
+19
View File
@@ -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;
+19 -1
View File
@@ -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";
}
+18 -3
View File
@@ -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);
+3 -1
View File
@@ -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";
}
+10 -1
View File
@@ -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";
}
+11 -7
View File
@@ -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;
+9 -1
View File
@@ -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";
}
+15 -3
View File
@@ -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,
};
}
+3 -1
View File
@@ -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";
}
+3 -1
View File
@@ -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;
+13 -4
View File
@@ -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 ?? "",
},
]),
];
+10 -1
View File
@@ -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";
}
+2 -4
View File
@@ -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