Files
outline/app/editor/extensions/BlockMenu.tsx
T
Tom Moor 8c716b173a 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>
2026-05-02 18:54:27 -04:00

137 lines
3.9 KiB
TypeScript

import { action } from "mobx";
import { PlusIcon } from "outline-icons";
import { Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
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";
/**
* 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: "/",
allowSpaces: false,
requireSearchTerm: false,
enabledInCode: false,
};
}
get name() {
return "block-menu";
}
get plugins() {
const button = document.createElement("button");
button.className = "block-menu-trigger";
button.type = "button";
ReactDOM.render(<PlusIcon />, button);
return [
...super.plugins,
new Plugin({
props: {
decorations: (state) => {
const parent = findParentNode(
(node) => node.type.name === "paragraph"
)(state.selection);
if (!parent) {
return;
}
const isTopLevel = state.selection.$from.depth === 1;
if (!isTopLevel) {
return;
}
const decorations: Decoration[] = [];
const isEmptyNode = parent && parent.node.content.size === 0;
if (isEmptyNode) {
decorations.push(
Decoration.widget(
parent.pos,
() => {
button.onclick = action(() => {
this.state.query = "";
this.state.open = true;
});
return button;
},
{
key: "block-trigger",
}
)
);
}
return DecorationSet.create(state.doc, decorations);
},
},
}),
new PlaceholderPlugin([
{
condition: ({ node, $start, textContent, state }) =>
$start.depth === 1 &&
state.selection.$from.pos === $start.pos + node.content.size &&
!!textContent &&
node.childCount === 0 &&
node.textContent === "",
text: this.options.dictionary.newLineEmpty,
},
{
condition: ({ node, $start, state }) =>
$start.depth === 1 &&
state.selection.$from.pos === $start.pos + node.content.size &&
node.textContent === "/",
text: ` ${this.options.dictionary.newLineWithSlash}`,
},
]),
];
}
private handleClose = action((insertNewLine: boolean) => {
const { view } = this.editor;
if (insertNewLine) {
const transaction = view.state.tr.split(view.state.selection.to);
view.dispatch(transaction);
view.focus();
}
this.state.open = false;
});
widget = ({ rtl }: WidgetProps) => {
const { props } = this.editor;
return (
<BlockMenu
rtl={rtl}
trigger={this.options.trigger}
isActive={this.state.open}
search={this.state.query}
onClose={this.handleClose}
uploadFile={props.uploadFile}
onFileUploadStart={props.onFileUploadStart}
onFileUploadStop={props.onFileUploadStop}
onFileUploadProgress={props.onFileUploadProgress}
embeds={props.embeds}
/>
);
};
}