diff --git a/app/editor/extensions/BlockMenu.tsx b/app/editor/extensions/BlockMenu.tsx index a1551ad19f..59f3561c5d 100644 --- a/app/editor/extensions/BlockMenu.tsx +++ b/app/editor/extensions/BlockMenu.tsx @@ -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 { get defaultOptions() { return { trigger: "/", diff --git a/app/editor/extensions/FindAndReplace.tsx b/app/editor/extensions/FindAndReplace.tsx index d7be21a71c..db12f404ab 100644 --- a/app/editor/extensions/FindAndReplace.tsx +++ b/app/editor/extensions/FindAndReplace.tsx @@ -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 { public get name() { return "find-and-replace"; } - public get defaultOptions() { + public get defaultOptions(): FindAndReplaceOptions { return { caseSensitive: false, regexEnabled: false, diff --git a/app/editor/extensions/HoverPreviews.tsx b/app/editor/extensions/HoverPreviews.tsx index a485ea067a..d3ecb101b9 100644 --- a/app/editor/extensions/HoverPreviews.tsx +++ b/app/editor/extensions/HoverPreviews.tsx @@ -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 { state: { activeLinkElement: HTMLElement | null; unfurlId: string | null; diff --git a/app/editor/extensions/Multiplayer.ts b/app/editor/extensions/Multiplayer.ts index 87c469b509..27e3b5718c 100644 --- a/app/editor/extensions/Multiplayer.ts +++ b/app/editor/extensions/Multiplayer.ts @@ -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 { get name() { return "multiplayer"; } diff --git a/app/editor/extensions/SmartText.ts b/app/editor/extensions/SmartText.ts index 1b31b1ca97..29ab5c1505 100644 --- a/app/editor/extensions/SmartText.ts +++ b/app/editor/extensions/SmartText.ts @@ -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 { get name() { return "smart_text"; } diff --git a/app/editor/extensions/Suggestion.ts b/app/editor/extensions/Suggestion.ts index 0bb8342e5f..6904568bb2 100644 --- a/app/editor/extensions/Suggestion.ts +++ b/app/editor/extensions/Suggestion.ts @@ -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 { + constructor(options: TOptions) { super(options); const triggers = Array.isArray(this.options.trigger) diff --git a/app/editor/extensions/index.ts b/app/editor/extensions/index.ts index 5a4b592376..0b87b7a105 100644 --- a/app/editor/extensions/index.ts +++ b/app/editor/extensions/index.ts @@ -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, diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 40d212bf8d..abb547f3c9 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -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 */ diff --git a/shared/editor/commands/link.ts b/shared/editor/commands/link.ts index 1306af71d4..14f4e58c6a 100644 --- a/shared/editor/commands/link.ts +++ b/shared/editor/commands/link.ts @@ -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 + onClickLink: + | (( + url: string, + event?: + | KeyboardEvent + | MouseEvent + | React.MouseEvent + ) => 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 + onClickLink: + | (( + url: string, + event?: + | KeyboardEvent + | MouseEvent + | React.MouseEvent + ) => 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 + onClickLink: + | (( + url: string, + event?: KeyboardEvent | MouseEvent | React.MouseEvent + ) => void) + | undefined, + dictionary: { openLinkError: string } ): Command => chainCommands( openLinkTextSelection(onClickLink, dictionary), diff --git a/shared/editor/extensions/Diff.ts b/shared/editor/extensions/Diff.ts index d4e84fc98b..b5469f365d 100644 --- a/shared/editor/extensions/Diff.ts +++ b/shared/editor/extensions/Diff.ts @@ -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 { 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; } diff --git a/shared/editor/extensions/MaxLength.ts b/shared/editor/extensions/MaxLength.ts index 8ace4eb8af..1ae02ac9e4 100644 --- a/shared/editor/extensions/MaxLength.ts +++ b/shared/editor/extensions/MaxLength.ts @@ -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 { get name() { return "maxlength"; } diff --git a/shared/editor/extensions/TrailingNode.ts b/shared/editor/extensions/TrailingNode.ts index 553a93c7f8..45d56ec58b 100644 --- a/shared/editor/extensions/TrailingNode.ts +++ b/shared/editor/extensions/TrailingNode.ts @@ -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 { get name() { return "trailing_node"; } - get defaultOptions() { + get defaultOptions(): TrailingNodeOptions { return { node: "paragraph", notAfter: ["paragraph", "heading"], diff --git a/shared/editor/lib/Extension.ts b/shared/editor/lib/Extension.ts index db13071099..2639e2501b 100644 --- a/shared/editor/lib/Extension.ts +++ b/shared/editor/lib/Extension.ts @@ -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 { + options: TOptions; editor: Editor; - // oxlint-disable-next-line @typescript-eslint/no-explicit-any - constructor(options: Record = {}) { + constructor(options: Partial = {}) { this.options = { ...this.defaultOptions, ...options, - }; + } as TOptions; } bindEditor(editor: Editor) { @@ -45,7 +43,7 @@ export default class Extension { return []; } - get defaultOptions() { + get defaultOptions(): Partial { return {}; } diff --git a/shared/editor/lib/ExtensionManager.ts b/shared/editor/lib/ExtensionManager.ts index 12150bfdae..a58bad09be 100644 --- a/shared/editor/lib/ExtensionManager.ts +++ b/shared/editor/lib/ExtensionManager.ts @@ -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 + ) => 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 = {}; - // @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; + 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, diff --git a/shared/editor/lib/types.ts b/shared/editor/lib/types.ts new file mode 100644 index 0000000000..4853ae12c9 --- /dev/null +++ b/shared/editor/lib/types.ts @@ -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 | Mark | Node; + +/** + * 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; diff --git a/shared/editor/marks/Comment.ts b/shared/editor/marks/Comment.ts index e073bde68a..3492926fa6 100644 --- a/shared/editor/marks/Comment.ts +++ b/shared/editor/marks/Comment.ts @@ -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 { get name() { return "comment"; } diff --git a/shared/editor/marks/Link.tsx b/shared/editor/marks/Link.tsx index b474b30b98..dbe46099f1 100644 --- a/shared/editor/marks/Link.tsx +++ b/shared/editor/marks/Link.tsx @@ -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 + ) => void; +}; + +export default class Link extends Mark { 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); diff --git a/shared/editor/marks/Mark.ts b/shared/editor/marks/Mark.ts index d89543f519..180a777743 100644 --- a/shared/editor/marks/Mark.ts +++ b/shared/editor/marks/Mark.ts @@ -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 { get type() { return "mark"; } diff --git a/shared/editor/nodes/Attachment.tsx b/shared/editor/nodes/Attachment.tsx index a1b0e416f1..ec7923397f 100644 --- a/shared/editor/nodes/Attachment.tsx +++ b/shared/editor/nodes/Attachment.tsx @@ -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 { get name() { return "attachment"; } diff --git a/shared/editor/nodes/CodeFence.ts b/shared/editor/nodes/CodeFence.ts index 23b9f7eadb..3cbd1df549 100644 --- a/shared/editor/nodes/CodeFence.ts +++ b/shared/editor/nodes/CodeFence.ts @@ -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 { /** Plugin key for the collapse state, shared with the command. */ private static readonly collapseKey = new PluginKey( "collapse-code-block" ); - constructor(options: { - dictionary: Dictionary; - userPreferences?: UserPreferences | null; - }) { - super(options); - } get showLineNumbers(): boolean { return this.options.userPreferences?.codeBlockLineNumbers ?? true; diff --git a/shared/editor/nodes/Doc.ts b/shared/editor/nodes/Doc.ts index c44811c0d9..7f6670e996 100644 --- a/shared/editor/nodes/Doc.ts +++ b/shared/editor/nodes/Doc.ts @@ -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 { get name() { return "doc"; } diff --git a/shared/editor/nodes/Heading.ts b/shared/editor/nodes/Heading.ts index 477522afca..55b0881bf6 100644 --- a/shared/editor/nodes/Heading.ts +++ b/shared/editor/nodes/Heading.ts @@ -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 { get name() { return "heading"; } - get defaultOptions() { + get defaultOptions(): Partial { return { levels: [1, 2, 3, 4], - collapsed: undefined, }; } diff --git a/shared/editor/nodes/Node.ts b/shared/editor/nodes/Node.ts index 8e206857e9..862a3cd6c8 100644 --- a/shared/editor/nodes/Node.ts +++ b/shared/editor/nodes/Node.ts @@ -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 { get type() { return "node"; } diff --git a/shared/editor/nodes/ReactNode.ts b/shared/editor/nodes/ReactNode.ts index f193cc293b..12f2730aa5 100644 --- a/shared/editor/nodes/ReactNode.ts +++ b/shared/editor/nodes/ReactNode.ts @@ -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 { abstract component: ( props: Omit ) => React.ReactElement; diff --git a/shared/editor/nodes/ToggleBlock.ts b/shared/editor/nodes/ToggleBlock.ts index a9b2c5117a..be59c398c6 100644 --- a/shared/editor/nodes/ToggleBlock.ts +++ b/shared/editor/nodes/ToggleBlock.ts @@ -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 { 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 ?? "", }, ]), ]; diff --git a/shared/editor/nodes/Video.tsx b/shared/editor/nodes/Video.tsx index 4aaeb6f58a..f5e9179381 100644 --- a/shared/editor/nodes/Video.tsx +++ b/shared/editor/nodes/Video.tsx @@ -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 { get name() { return "video"; } diff --git a/shared/editor/nodes/index.ts b/shared/editor/nodes/index.ts index 5192808349..ff3d3be55b 100644 --- a/shared/editor/nodes/index.ts +++ b/shared/editor/nodes/index.ts @@ -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